GitLab CI/CD From Zero to Pipeline Architect: Part 2
In the first post, you built a pipeline that:
GitLab CI/CD Variables & Precedence: Turning Pipelines Into Smart Machines
- Has multiple stages (.pre, build, test, deploy, .post)
- Uses artifacts to pass files between jobs
- Uses a variable (APP_VERSION) to give every build a unique version
That’s already better than a lot of real-world projects.
In this post, we’re going to zoom in on variables and make your pipelines feel like they have a brain:
- Where variables actually live (there are more places than you think)
- What happens when the same variable name is defined in multiple places
- How to use variables to control environments, branches, and behavior
- How to generate variables at runtime and pass them between jobs
- And finally: some advanced patterns used in bigger teams
By the end, you’ll know how to design pipelines that behave differently in dev, staging, and prod with just a few smart variables.
1. Why variables matter so much
Think of your pipeline like a remote-controlled robot:
- The YAML file is its body.
- The shell scripts are its muscles.
- Variables are the remote control signals.
Without variables, your pipeline is stuck with:
- Hardcoded URLs
- Hardcoded versions
- Hardcoded secrets (no, there will be significant security compliance)
With variables, you can say things like:
- “If this is the main branch, deploy to production.”
- “If this is a feature branch, only run tests.”
- “In dev, use this database URL. In prod, use that one.”
Variables turn a single pipeline definition into many different behaviors.
2. All the places a variable can live
GitLab lets you define variables in several places. That’s powerful, but confusing until you see the list clearly.
2.1 Inside .gitlab-ci.yml
You can define variables:
Globally (top-level):
variables:
APP_NAME: "cool-app"
DEFAULT_ENV: "dev"These are available to every job in the pipeline.
Per job:
deploy_staging:
stage: deploy
variables:
TARGET_ENV: "staging"
script:
- echo "Deploying $APP_NAME to $TARGET_ENV"Here:
- APP_NAME comes from the global block.
- TARGET_ENV exists only inside deploy_staging.
If a job variable has the same name as a global one, the job variable wins for that job.
2.2 In GitLab’s UI (project / group settings)
In GitLab, go to:
Here you define things like:
- DB_PASSWORD
- PROD_API_TOKEN
- SLACK_WEBHOOK_URL
These are not stored in git, so they’re:
- safer for secrets,
- easier to rotate,
- shared across pipelines for that project (or even a group).
2.3 As pipeline variables at run time
When you click “Run pipeline” in GitLab’s UI, you can pass extra variables:
- DEPLOY_ENV=preview
- RUN_HEAVY_TESTS=true
Or when you trigger a pipeline from another project, you can pass variables under a trigger: block (we’ll touch that in the advanced section).
These “run-time” variables have very high priority.
2.4 As dotenv variables generated by jobs
Jobs can write variables into a file and expose them to future jobs using artifacts:reports:dotenv.
Example:
compute_version:
stage: validate
script:
- VERSION="1.0.0-${CI_COMMIT_SHORT_SHA}"
- echo "APP_VERSION=$VERSION" > build.env
artifacts:
reports:
dotenv: build.envAny job that needs: ["compute_version"] will receive APP_VERSION as an environment variable.
This is perfect for:
- dynamic versions,
- flags computed from the repo,
- passing computed values to many jobs.
3. Variable precedence: who wins when names clash?
Now for the big question:
GitLab has a defined order of precedence (from strongest to weakest).
Rough mental model (simplified but accurate enough for daily use):
- Pipeline-level variables for that run
(manual pipeline form, triggers, schedules, API calls) - Project variables (CI/CD variables in project settings)
- Group variables
- Instance-level variables (self-hosted GitLab admins)
- Variables from dotenv reports (generated by jobs)
- Job-level variables in .gitlab-ci.yml
- Global YAML variables (variables: at the top of .gitlab-ci.yml)
- Predefined & environment variables at the bottom
So if DEPLOY_ENV is defined in multiple places, the value closest to the actual pipeline run usually wins (e.g., a manual pipeline variable > project variable > YAML variable).
Inside the YAML only:
4. Designing a clean variable strategy
Let’s turn this into a smarter plan.
4.1 Use global YAML variables for non-secret defaults
variables:
APP_NAME: "demo-app"
DEFAULT_ENV: "dev"
REGISTRY: "registry.example.com"
IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"These are:
- non-sensitive,
- project-specific,
- used by many jobs.
4.2 Use job variables to tweak behavior per job
deploy_dev:
stage: deploy
variables:
TARGET_ENV: "dev"
script:
- ./deploy.sh "$TARGET_ENV" "$REGISTRY/$APP_NAME:$IMAGE_TAG"deploy_prod:
stage: deploy
variables:
TARGET_ENV: "prod"
script:
- ./deploy.sh "$TARGET_ENV" "$REGISTRY/$APP_NAME:$IMAGE_TAG"Same deploy script, different TARGET_ENV → different behavior.
4.3 Use project/group variables for secrets and environment-specific values
Examples (defined in CI/CD settings, not in git):
- DB_PASSWORD_DEV
- DB_PASSWORD_PROD
- CLOUD_API_KEY
- SLACK_WEBHOOK_URL
Then in YAML:
deploy_dev:
stage: deploy
script:
- ./deploy.sh dev "$DB_PASSWORD_DEV"deploy_prod:
stage: deploy
script:
- ./deploy.sh prod "$DB_PASSWORD_PROD"No passwords in the repo. If a password changes, you update it in one place: the CI/CD settings.
4.4 Use dotenv for dynamic stuff
We already saw this pattern:
compute_version:
stage: validate
script:
- VERSION="1.0.0-${CI_COMMIT_SHORT_SHA}"
- echo "APP_VERSION=$VERSION" > vars.env
artifacts:
reports:
dotenv: vars.envThen later jobs just reference APP_VERSION:
build_app:
stage: build
needs: ["compute_version"]
script:
- echo "Building $APP_NAME version $APP_VERSION"
- # build commands...deploy_to_staging:
stage: deploy
needs: ["build_app"]
script:
- echo "Deploying version $APP_VERSION to staging..."One job decides the version; everyone else uses it.
5. Making pipelines react to branches and environments
Variables don’t just hold data; they can control when jobs run using rules:.
5.1 Example: different behavior on main vs feature branches
run_tests:
stage: test
script: npm test
rules:
- if: '$CI_MERGE_REQUEST_IID'
when: on_success # run on every merge request
- if: '$CI_COMMIT_BRANCH == "main"'
when: on_success # also run on direct pushes to main
- when: never # never for other branchesHere we use predefined variables:
- CI_MERGE_REQUEST_IID – set when this is an MR pipeline.
- CI_COMMIT_BRANCH – the current branch name.
You can mix these with your own variables:
deploy_staging:
stage: deploy
script: ./deploy.sh staging
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
allow_failure: falseThis means:
- Only main can trigger deploy to staging.
- It’s manual, and must succeed (can’t be ignored).
6. A “serious” pipeline with smart variables
Let’s upgrade the pipeline from Post 1 into something more dynamic.
6.1 Goals
We want:
- A standard stage layout:
stages: - validate - build - test - deploy- A job that calculates a dynamic APP_VERSION.
- Artifacts shared between jobs.
- Deploy only on main, and only manually.
6.2 Full example
stages:
- validate
- build
- test
- deploy# Global defaults
variables:
APP_NAME: "demo-app"
REGISTRY: "registry.example.com"
DEFAULT_ENV: "dev"# 1) Compute a dynamic version and share it
compute_version:
stage: validate
script:
- echo "Computing version..."
- VERSION="1.0.0-${CI_COMMIT_SHORT_SHA}"
- echo "APP_VERSION=$VERSION" > vars.env
artifacts:
reports:
dotenv: vars.env# 2) Build using that version
build_app:
stage: build
needs: ["compute_version"]
script:
- echo "Building $APP_NAME version $APP_VERSION"
- mkdir -p build/
- echo "Binary for version $APP_VERSION" > build/app-binary
artifacts:
paths:
- build/# 3) Test using the artifact
test_app:
stage: test
needs: ["build_app"]
script:
- echo "Testing version $APP_VERSION"
- if [ -f build/app-binary ]; then echo "Artifact OK"; else echo "Missing artifact!"; exit 1; fi# 4) Deploy only from main, manually
deploy_to_env:
stage: deploy
needs: ["test_app"]
variables:
TARGET_ENV: "staging"
script:
- echo "Deploying $APP_NAME version $APP_VERSION to $TARGET_ENV..."
- cat build/app-binary
# real deployment would go here (scp, kubectl, helm, etc.)
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
allow_failure: false
- when: neverYou now have:
- Dynamic versions,
- Artifacts flowing between jobs,
- Manual gated deploys,
- Branch-aware behavior.
This feels like a real production-style pipeline, not a toy.
7. Debugging variables when things feel “magic”
Sometimes it feels like “GitLab is ignoring my variable”. When that happens, do this.
7.1 Add a debug job
debug_variables:
stage: validate
script:
- echo "Branch: $CI_COMMIT_BRANCH"
- echo "APP_NAME: $APP_NAME"
- echo "APP_VERSION: $APP_VERSION"
- echo "TARGET_ENV: $TARGET_ENV"
- echo "---- ALL VARIABLES ----"
- env | sort
when: manual- Run this job manually.
- Check what values you actually have at runtime.
7.2 Checklist for weird behavior
- Did you define the variable in the right place (YAML vs project vs group)?
- Are you using the same name in multiple places?
- Did you override it with a pipeline variable when running?
- Did you set up needs: correctly so dotenv variables reach the right jobs?
For YAML only:
- Remember: job variable beats global variable in that job.
8. Advanced Variable Patterns (for when you want to feel like a CI architect)
The basics are enough for small projects. But if you want “professional-level” pipelines, here are more advanced tricks you can use.
8.1 Environment-scoped & protected variables
In project/group CI/CD settings, variables support:
- Environment scope — e.g. prod/*, staging, or *.
- Protected — only available on protected branches/tags.
- Masked — value hidden in logs.
Example strategy:
- API_URL with scope staging → https://staging.api.example.com
- API_URL with scope prod → https://api.example.com
- Both called API_URL, but GitLab picks the matching scope based on the environment.
Then in your job:
deploy:
stage: deploy
environment:
name: production
script:
- echo "Deploying to $API_URL"Same YAML, but:
- When the environment is staging, it uses the staging API_URL.
- When the environment is production, it uses the prod API_URL.
Combined with protected branches/tags, you can ensure prod secrets only work on prod pipelines.
8.2 Mapping branches to environments using variables
You don’t always want a separate job for each environment. You can map branches to environments using a bit of shell and variables.
deploy:
stage: deploy
script:
- |
case "$CI_COMMIT_BRANCH" in
main)
TARGET_ENV="production"
;;
develop)
TARGET_ENV="staging"
;;
*)
TARGET_ENV="dev"
;;
esac
echo "Deploying branch $CI_COMMIT_BRANCH to $TARGET_ENV"
./deploy.sh "$TARGET_ENV" "$REGISTRY/$APP_NAME:$IMAGE_TAG"
rules:
- if: '$CI_COMMIT_BRANCH =~ /^(main|develop|feature\/)/'
when: manual
- when: neverNow one deploy job handles:
- main → production
- develop → staging
- others → dev
… all controlled by variables and a simple case block.
8.3 Rule-level variable overrides
You can change variables per rule, not only per job. That means:
- Same job,
- Different variables depending on condition.
deploy:
stage: deploy
script:
- echo "Deploying to $TARGET_ENV"
- ./deploy.sh "$TARGET_ENV"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual
variables:
TARGET_ENV: "production"
- if: '$CI_COMMIT_BRANCH == "develop"'
when: manual
variables:
TARGET_ENV: "staging"
- when: neverHere:
- On main, TARGET_ENV=production.
- On develop, TARGET_ENV=staging.
- Everything else: job doesn’t run.
This is super clean because the logic is all in rules, not in a big if inside the script.
8.4 Passing variables into child / multi-project pipelines
When you trigger another pipeline, you can pass variables to it.
Child pipeline (same project):
run_child:
stage: deploy
trigger:
include: "child-pipeline.yml"
strategy: depend
variables:
APP_NAME: "$APP_NAME"
APP_VERSION: "$APP_VERSION"Multi-project pipeline:
deploy_config:
stage: deploy
trigger:
project: my-org/infrastructure-config
branch: main
strategy: depend
variables:
APP_NAME: "$APP_NAME"
APP_VERSION: "$APP_VERSION"
TARGET_ENV: "production"The triggered pipeline sees those variables as if they were defined in its own environment.
This is how bigger companies separate:
- App repositories (code + images)
- Infra / GitOps repositories (manifests + cluster config)
…but still keep them in sync using variables.
8.5 Using default values and safety nets in bash
When writing scripts in script: you can give variables default values:
# in a script section
: "${TARGET_ENV:=dev}" # default to dev if empty
: "${IMAGE_TAG:=$CI_COMMIT_SHORT_SHA}"echo "Env: $TARGET_ENV"
echo "Image tag: $IMAGE_TAG"So if someone forgets to set TARGET_ENV, it quietly becomes "dev" instead of breaking.
8.6 Variable hygiene: do’s and don’ts
Do:
- Use clear names: TARGET_ENV, APP_VERSION, REGISTRY_URL.
- Group related variables with prefixes: DEV_DB_*, PROD_DB_*.
- Document what variables your pipeline expects (even in comments).
Don’t:
- Hardcode secrets in .gitlab-ci.yml.
- Reuse variable names for completely different meanings.
- Mix environment names and URLs in the same variable (keep them separate).
9. Recap
In this post, you went way beyond simple $APP_VERSION:
- You learned where variables can live (YAML, UI, pipeline run, dotenv).
- You saw who wins when the same name appears in multiple places.
- You designed a clean strategy:
1. YAML → defaults.
2. Job variables → per-job tweaks.
3. Project/group variables → secrets and env-specific data.
4. Dotenv → dynamic data from earlier jobs. - You built a pipeline that:
1. Computes versions,
2. Passes artifacts across stages,
3. Deploys conditionally based on branch,
4. Uses manual gates for deploys. - You explored advanced patterns:
1. Environment-scoped variables,
2. Branch→environment mapping,
3. Rule-level variable overrides,
4. Passing variables to child/multi-project pipelines,
5. Shell defaults for extra safety.
Conclusion
- Variables act as the “remote control” for your pipelines, enabling you to automate different behaviors for development, staging, and production without changing the code.
- We demystified the hierarchy of where variables live — from global YAML and UI settings to dynamic runtime values — and the precedence rules that determine which one wins.
- You learned a clean strategy for managing data: using global variables for defaults, project settings for secrets, and dotenv artifacts for dynamic information.
- We applied this knowledge to build a “serious” pipeline that features dynamic versioning, manual deployment gates, and efficient artifact passing.
- Finally, we covered advanced architect-level patterns like environment scoping and rule-based overrides to give you precise control over complex workflows.
Comments
Questions, corrections, war stories — all welcome. Sign in with GitHub to join the discussion.