Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

GitLab Pipeline Variables: A Practical Map of Every Kind and Where to Put Each One

Sean

Platform Writer

Jun 19, 2026
9 min read

GitLab CI/CD variables have eight scopes — instance, group, project, pipeline schedule, manual pipeline run, trigger, job, and variables:/include: in the .gitlab-ci.yml — and each one has a different blast radius, a different edit surface, and a different priority when two variables have the same name. The reason “gitlab pipeline variables” is still a heavily searched query is that the system has been adding scopes for a decade, and the docs treat them as a list rather than a decision.

The interesting work is not “how do I set a variable” — that has been one click in the UI since 2014. The interesting work is: which scope for which secret, what the precedence order means for a job that depends on five overlapping sources, and how to keep the production secret out of the developer’s branch.

GitLab pipeline variables: a practical map of every kind and where to put each one

Table of contents

The eight scopes, ranked by blast radius

The list, from widest reach to narrowest:

  1. Instance — set in Admin Area > Settings > CI/CD. Available to every project on the GitLab instance. The right place for things that are truly global, like a shared container registry credential. The wrong place for almost everything else, because the blast radius is “every pipeline in the company.”
  2. Group — set in Group > Settings > CI/CD. Available to every project in the group. The right place for a shared deploy key, an artifact registry token, or a database connection string that every project in the group uses. The blast radius is “every project in the group.”
  3. Project — set in Project > Settings > CI/CD. Available to every pipeline in the project. The right place for project-specific secrets: a deploy key for the project’s production environment, an API token for the project’s third-party service, a connection string for the project’s database.
  4. Pipeline schedule — set when creating a scheduled pipeline. Available only to pipelines triggered by that schedule. The right place for the “deploy staging on Sunday at 2 a.m.” jobs that need a slightly different secret than the manual deploy.
  5. Manual pipeline run — set when clicking “Run pipeline” with custom variables. Available only to that one pipeline run. The right place for a one-off debug deploy with a temporary value.
  6. Trigger — set when a pipeline is triggered via the API (e.g., from an upstream pipeline or an external scheduler). The right place for cross-project pipelines that need to pass context between jobs.
  7. Job — set in the variables: block of a specific job in .gitlab-ci.yml. Available only to that job. The right place for non-secret configuration that does not belong in the broader project: a per-job timeout, a feature flag for a specific job, a cache key prefix.
  8. include: — variables pulled in from another YAML file, typically a ci-tokens.yml or a ci-config.yml referenced by include:. The right place for shared configuration across many projects, especially the tokens and settings that the security team owns.

The list ordering is also the priority order, with one important exception: trigger variables override manual run variables override pipeline schedule variables override project variables, all the way down. The next section is the full precedence order in one place.

The precedence order, in the order GitLab applies them

When two variables have the same name, GitLab picks the higher-priority one. The order, lowest priority to highest:

  1. Instance variables
  2. Group variables
  3. Project variables (regular)
  4. Project variables (protected + protected branch)
  5. Pipeline schedule variables
  6. Manual pipeline run variables (the Run pipeline form)
  7. Trigger variables (from the API)
  8. Job-level variables: in .gitlab-ci.yml
  9. include: files (later includes override earlier ones)
  10. script: section variables (export FOO=bar in the script, the highest priority)

The most-missed rule: a job-level variable overrides a project variable, which overrides a group variable, which overrides an instance variable. A variable set in variables: in the YAML wins over the same-named variable set in Project > Settings. That is the right behavior for “this job is special,” but it is also why a “I updated the token in Settings and the pipeline still uses the old one” report usually turns out to be a job-level shadow.

The other most-missed rule: variables in script: (the export block) win over everything. This is sometimes the right answer (the script knows best) and sometimes a debugging nightmare (the secret is in the YAML in plaintext, and the override makes the visible value look wrong).

The four variable types that change the security story

GitLab has four types of variables, and the difference between them is the only thing that keeps a leaked secret from being a CVE.

Regular — the default. The value is stored in the database in cleartext. It is exposed in the job log if a script echoes it. The right choice for non-secret configuration.

Masked — the value is replaced with [masked] in the job log. The value is still stored in cleartext in the database. The right choice for most secrets, with the caveat that masking only works if the value is not part of a longer string. A secret that contains a + or - is often not masked correctly because the masking algorithm looks for exact matches. Test the mask before trusting it.

Protected — the variable is only available to pipelines running on protected branches or protected tags. The right choice for production secrets: a deploy key that should not leak to a developer’s branch, a production database connection string that should not be reachable from a feature branch. The combination of “protected + masked” is the gold standard for production secrets.

File — the variable is written to a temporary file, and the variable contains the path to that file. The right choice for secrets that are too large for a single env var (a Kubernetes config, a multi-line certificate), and for tools that natively read a file rather than an env var (SSH keys, GCP service account JSON, ~/.netrc).

A real production setup uses all four. A non-secret build flag is a regular variable. A deploy token is masked. A production secret is masked + protected. A service account JSON is a file variable.

A reference setup for a team with three environments

For a team with dev, staging, and production environments, the variable layout that works is:

VariableScopeTypeValue
REGISTRY_USERGroupMaskedThe shared CI user
REGISTRY_PASSWORDGroupMasked + ProtectedThe shared CI password
DATABASE_URLProjectMasked + ProtectedThe production connection string
STAGING_DATABASE_URLProjectMaskedThe staging connection string
FEATURE_FLAG_NEW_BILLINGProject (dev only)RegularThe dev-only feature flag
KUBECONFIGProjectFile (Protected)The path to the staging kubeconfig file
PROD_KUBECONFIGProjectFile (Protected)The path to the production kubeconfig file

The pattern: group-level for shared infrastructure, project-level for the things the project owns, and a clear split between protected (production) and non-protected (staging + dev). The FEATURE_FLAG_NEW_BILLING variable is intentionally regular and non-protected — it has to be readable from any branch so feature work can test against it.

The .gitlab-ci.yml references them as $DATABASE_URL (or ${DATABASE_URL} in shell scripts). The values are interpolated at job start, not at YAML parse time, so a variable change in Settings takes effect on the next pipeline without re-committing the YAML.

The migration from CI/CD settings to include: files

For a team with more than a handful of projects, the right pattern is to move variables and templates out of the UI and into version-controlled YAML files. The shape is:

# .gitlab/ci/tokens.yml (referenced by include)
variables:
  REGISTRY_USER: "ci-shared-user"
  REGISTRY_PASSWORD:
    description: "Shared registry password"
    value: "masked-in-source-by-CI"
# .gitlab-ci.yml
include:
  - local: ".gitlab/ci/tokens.yml"

The trade-off: the values now live in the repo, which is the wrong place for production secrets. The right answer is to keep non-secret variables in the include file and pull secrets from the project settings. The include file owns the structure; the settings own the values.

For a team that has outgrown both — many projects, many environments, a security team that owns the secrets — the next step is a “variable as a service” pattern: a small project whose only job is to expose a tokens.yml to other projects via include, with the values injected at fetch time from a secret store. The build is the same; the source of truth is now decoupled from the repo. For a hosted equivalent, the MCP server pattern is one way to expose those tokens to AI tools; for non-AI pipelines, a small API that the CI job calls is the same shape.

The protection flags and what they actually do

The protection flags in Project > Settings > CI/CD are:

  • Protected branches / tags — limits the variable to pipelines on protected refs. Unprotected branches cannot see the variable. The right setting for any production secret.
  • Expand variable reference — allows the variable’s value to be expanded (e.g., $OTHER_VAR in the value is resolved to the value of OTHER_VAR). The right setting for variables that reference other variables; the wrong setting for secrets that should be passed through verbatim.
  • Mask but allow for use in pipeline inputs — a newer flag for the pipeline inputs feature. Allow the variable to be masked in the job log but referenced as a pipeline input without re-masking.
  • Hide variable in CI/CD logs beyond masking — for variables that the job absolutely should not be able to echo, even in error paths. The right setting for the highest-value secrets.

The trap: a protected variable only protects against the wrong branch reading it. A developer with the right branch can still see the value in the job log (unless masked), in a debug job they trigger themselves, or in any artifact the job uploads. Protection is necessary but not sufficient. The full security model is: protected + masked + small blast radius (use the project scope, not the instance scope) + audit logs (turn on audit events for the project and review them).

The debugging checklist when a variable is not resolving

The “my variable is not being read” report follows one of five patterns.

The variable is in the wrong scope. Confirm with Project > Settings > CI/CD > Variables. The scope dropdown shows where the variable is reachable.

The variable is protected and the branch is not. Move the branch to protected, or move the variable off protected (and accept the security trade-off).

A job-level variables: block shadows the project variable. The job wins. Either rename the project variable or remove the job-level override.

The script does not see $VAR because the shell stripped it. Use ${VAR} instead of $VAR in shell scripts. The first form is unambiguous; the second is sometimes parsed as $VA followed by R in older shells.

The variable is referenced in a script: block but the shell exited before the reference. A failed set -e two lines before the reference kills the rest of the script. Remove set -e to see the real error, or use bash -x in the script for a trace.

The fastest diagnostic: add a debug job that does nothing but env | sort and printenv | grep -i FOO. The output shows exactly which variables the job saw, in which order. From there, the fix is usually one of the five patterns above.

How this fits the rest of the stack

The CI/CD pipeline is also a hosting cost — the build minutes, the runner time, the storage for artifacts, and the bandwidth for downloads each show up as a separate line item. The team’s mental model for the pipeline cost is the sum of those numbers, and the team should know the total before the pipeline gets busy. The RunxBuild hosting calculator is the right place to model that — pick the build frequency, the runner size, the artifact storage, and the bandwidth, and the calculator shows what the pipeline costs at the team’s actual usage.

Useful related references:

FAQ

How do I set a GitLab CI/CD variable?

Project > Settings > CI/CD > Variables > “Add variable.” Choose the scope (which environments see it), the type (regular, masked, file), and the protection (which branches see it). For a variable that the YAML should know about, add a variables: block at the top level or under a specific job.

What is the difference between masked and protected?

Masked hides the value in the job log. Protected limits the variable to pipelines on protected branches or tags. The two are independent — a variable can be masked without being protected, or protected without being masked. For production secrets, set both.

How do I pass a variable to a downstream pipeline via trigger?

Include the variable in the triggers: block of the downstream project’s .gitlab-ci.yml, or pass it in the variables: payload of the trigger API call. The downstream pipeline can read it as a normal variable.

Why is my GitLab variable showing the wrong value?

Most often, a job-level variables: block in .gitlab-ci.yml is shadowing the project variable. Job variables have higher priority than project variables. The fix is either to rename the project variable or remove the override.

Can I use a GitLab variable as a file path?

Yes — set the variable type to “File” in the UI. The value you enter is the file content, and GitLab writes it to a temp file and exposes the path as the variable name. Useful for kubeconfig, SSH keys, and GCP service account JSON.

How do I keep secrets out of the YAML?

Set the secret in Project > Settings > CI/CD > Variables as a masked, protected variable. The YAML references it as $SECRET_NAME, and the value is interpolated at job start. Never commit the actual secret to the repo.

#gitlab pipeline variables#gitlab ci variables#ci/cd variables#gitlab masked variables#gitlab environment variables#gitlab secret management