← All articles
CI/CDDec 7, 202511 min read

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:

Settings → CI/CD → Variables

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.env

Any 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:

What happens if the same variable name is defined in multiple places?

GitLab has a defined order of precedence (from strongest to weakest).

Rough mental model (simplified but accurate enough for daily use):

  1. Pipeline-level variables for that run
    (manual pipeline form, triggers, schedules, API calls)
  2. Project variables (CI/CD variables in project settings)
  3. Group variables
  4. Instance-level variables (self-hosted GitLab admins)
  5. Variables from dotenv reports (generated by jobs)
  6. Job-level variables in .gitlab-ci.yml
  7. Global YAML variables (variables: at the top of .gitlab-ci.yml)
  8. 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:

Job variable > global variable for that job.

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.env

Then 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 branches

Here 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: false

This 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: never

You 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:

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: never

Now 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: never

Here:

  • 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.
MK
Mohankrishna PodileDevOps Engineer & Cloud Architect · Irving, Texas

Comments

Questions, corrections, war stories — all welcome. Sign in with GitHub to join the discussion.