5. Variables and Secrets
Earlier chapters used variables everywhere: in rules:if, in trigger jobs, in deploy scripts. This chapter explains where those variables come from. It covers predefined variables, variables you define in the YAML and in settings, the order that decides which value wins, and how values are expanded. It then turns to protection (masking, hiding, protected branches, environment scopes), to values created at runtime, and to fetching secrets from external secret stores with short-lived identity tokens. Getting this right decides whether a pipeline gets the correct configuration and whether production credentials stay out of reach of untrusted code.
<a id="51-cicd-variables-basics"></a>
5.1 CI/CD Variables Basics
A CI/CD variable is a named string that GitLab hands to a job, usually as an environment variable. This subchapter covers the sources of variables, how to define them, which value wins when several sources define the same name, and how references to other variables are expanded.
Predefined Variables
Why it matters. A job often needs to know about its own context: which commit it is building, which branch triggered it, where the container registry is, or whether it runs in a merge request. Hard-coding these values would make the configuration break on every new branch. GitLab provides them automatically as predefined variables, so one .gitlab-ci.yml adapts to every pipeline.
What it is. GitLab and the runner set a large set of variables for every job without any configuration. Most start with CI_, some with GITLAB_. Think of them as the job's "badge": a card that tells the job who it is, where it comes from and which resources it may use.
| Variable | Example value | Typical use |
|---|---|---|
CI_COMMIT_SHA / CI_COMMIT_SHORT_SHA | 1ecfd275... / 1ecfd275 | Tagging images and artifacts |
CI_COMMIT_BRANCH | main | Branch pipelines only. Empty in MR and tag pipelines |
CI_COMMIT_TAG | v2.1.0 | Tag pipelines only |
CI_COMMIT_REF_NAME / CI_COMMIT_REF_SLUG | feature/Login / feature-login | Branch or tag name. The slug is lowercase, URL- and DNS-safe |
CI_DEFAULT_BRANCH | main | Comparing against the default branch without hard-coding it |
CI_PIPELINE_SOURCE | push, merge_request_event, schedule, web, api, trigger, pipeline, parent_pipeline | Deciding which jobs run |
CI_PIPELINE_ID / CI_PIPELINE_IID | 98765 / 412 | Global ID and per-project counter |
CI_JOB_ID, CI_JOB_NAME, CI_JOB_URL | 554433, unit-tests | Logging and reporting |
CI_PROJECT_DIR | /builds/acme/shop | Absolute path of the checkout |
CI_PROJECT_PATH, CI_PROJECT_NAMESPACE | acme/shop, acme | Identifying the project |
CI_REGISTRY, CI_REGISTRY_IMAGE | registry.gitlab.com, registry.gitlab.com/acme/shop | Building and pushing images |
CI_REGISTRY_USER, CI_REGISTRY_PASSWORD | gitlab-ci-token, short-lived token | Logging in to the container registry |
CI_JOB_TOKEN | Short-lived token | Calling selected GitLab APIs as the job |
CI_MERGE_REQUEST_IID, CI_MERGE_REQUEST_TARGET_BRANCH_NAME | 57, main | MR pipelines only |
CI_SERVER_URL, CI_API_V4_URL | https://gitlab.com, https://gitlab.com/api/v4 | Portable API calls |
GITLAB_USER_LOGIN | jdoe | The user who started the pipeline or job |
Not every predefined variable exists at every moment. GitLab documents three levels of availability:
| Availability | When the value exists | Can be used in |
|---|---|---|
| Pre-pipeline | Before the configuration is even read | include:rules, plus everything below |
| Pipeline | When GitLab creates the pipeline | workflow:rules, rules:if, plus everything below |
| Job-only | Only when a runner starts the job | script, and runner-expanded keywords such as image and cache:key |
CI_COMMIT_BRANCH is a pipeline-level variable, so rules can use it. CI_JOB_ID, CI_JOB_TOKEN and CI_RUNNER_* are job-only, so rules cannot.
build-image:
image: docker:27
services: [docker:27-dind]
script:
# The registry credentials are predefined and expire with the job
- echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
# Branch slug + short SHA gives a unique, valid image tag for every commit
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHORT_SHA"
To see what a job actually receives, print selected variables. Avoid dumping the whole environment with env or export: the output contains tokens, and masking (section 5.2) only hides exact matches.
inspect:
script:
- env | grep -E '^CI_(COMMIT|PIPELINE|MERGE_REQUEST)_' | sort # selected, non-secret prefixes only
Predefined variables have the lowest precedence (see Variable Precedence), so a project or YAML variable with the same name silently replaces them. Never reuse a CI_ name for your own variables.
Key takeaways
- Predefined variables describe the job's context (commit, ref, pipeline, project, registry, user) and need no configuration.
- Availability matters: pre-pipeline variables work in
include:rules, pipeline variables inrules, job-only variables only once the job runs. CI_COMMIT_BRANCH,CI_COMMIT_TAGandCI_MERGE_REQUEST_*are each set only in their own pipeline types.- Do not dump the full environment in logs, and do not reuse
CI_names.
Practice
- (Beginner) Add a job that prints
CI_PIPELINE_SOURCE,CI_COMMIT_REF_NAME,CI_COMMIT_BRANCHandCI_COMMIT_TAG. Run it from a branch push, a tag and a merge request, and record which values are empty in each case.- (Intermediate) Write a job that calls the GitLab API using only
CI_API_V4_URL,CI_PROJECT_IDandCI_JOB_TOKENto download an artifact from another job in the same project. The job must work unchanged on GitLab.com and on a self-managed instance.- Interview question: "A rule
if: $CI_JOB_IDnever matches, whileif: $CI_PIPELINE_IDdoes. Why?" Hint: think about when each value comes into existence relative to rule evaluation.
Defining Variables in YAML
Why it matters. Many values are configuration rather than secrets: a tool version, a build mode, a feature toggle, a default log level. Keeping them in .gitlab-ci.yml puts them under version control and code review, next to the jobs that use them.
What it is. The variables keyword defines variables in two places:
- Top-level
variablesapply to every job (unless a job opts out withinherit:variables, section 2.4). - Job-level
variablesapply to that job only and override top-level values with the same name.
Values are always strings. Use quotes for anything that YAML might type differently ("1.20", "on", "0"), as section 2.1 explained. Names may contain letters, digits and underscores.
variables: # top-level: every job sees these
NODE_VERSION: "20.15" # quoted: YAML would otherwise read a float
LOG_LEVEL: "info"
APP_DIR: "$CI_PROJECT_DIR/app" # may reference other variables
build:
image: node:$NODE_VERSION # expanded by the runner before pulling
variables:
LOG_LEVEL: "debug" # job-level override of the global value
NODE_ENV: "production" # only this job sees it
script:
- cd "$APP_DIR"
- npm ci && npm run build
lint:
inherit:
variables: [NODE_VERSION] # opt in to only one global variable
image: node:$NODE_VERSION
script: [npx eslint .]
The long form adds metadata. description, value and options drive the Run pipeline form (section 5.3), and expand: false disables expansion (section 5.2):
variables:
DEPLOY_TARGET:
value: "staging" # the default value
description: "Environment for manual runs"
options: ["staging", "production"]
TEMPLATE:
value: "Hello $USER" # stays literally "Hello $USER"
expand: false
YAML-defined variables are visible to everyone who can read the repository, and they appear in plain text in merge request diffs. They are never the right place for passwords or tokens.
| Put it in YAML | Put it in settings (or a secret store) |
|---|---|
| Tool versions, flags, paths | Passwords, API keys, tokens, certificates |
| Values that should be code-reviewed | Values that differ per environment and must be protected |
| Values that must change together with the code | Values that rotate independently of the code |
Key takeaways
- Top-level
variablesapply to all jobs. Job-levelvariablesoverride them for one job. - Values are strings, so quote anything YAML could type as a number or boolean.
- The long form adds
description,value,optionsandexpand. - YAML variables are public within the project: never store secrets there.
Practice
- (Beginner) Define a global
GREETING: "hello"and override it in one of two jobs. Print it in both jobs and confirm the values.- (Intermediate) Refactor a pipeline where five jobs repeat
PYTHON_VERSION: 3.12andPIP_CACHE_DIRso both are defined once. Make one job that must not receivePIP_CACHE_DIRopt out usinginherit.- Interview question: "A colleague proposes storing a staging database password in
.gitlab-ci.ymlbecause 'staging is not sensitive'. What is your response?" Hint: think about repository readers, forks, git history and what staging credentials can reach.
Project, Group and Instance Variables
Why it matters. Secrets and environment-specific values must not live in the repository, and some values are shared by many projects: a registry mirror URL, a monitoring endpoint, a company-wide license key. GitLab stores such variables in settings at three levels of its hierarchy, so you define each value once at the right scope and every project below inherits it.
What it is. Variables defined in the UI or API live outside the repository and are injected into jobs at runtime.
Instance (Admin area) e.g. HTTP_PROXY, INTERNAL_CA_CERT affects every project
└── Group acme e.g. SONAR_HOST_URL affects all projects in acme
└── Subgroup acme/payments e.g. PAYMENTS_API_URL overrides acme for the same name
└── Project shop e.g. DEPLOY_TOKEN most specific of the settings levels
| Level | Where | Who can manage it | Notes |
|---|---|---|---|
| Project | Settings > CI/CD > Variables | Maintainer or Owner | Up to 8000 variables per project |
| Group | Group Settings > CI/CD > Variables | Group Owner | Inherited by all subgroups and projects. Up to 30000 per group |
| Instance | Admin > Settings > CI/CD > Variables | Administrator (self-managed, Dedicated) | Inherited by every project on the instance |
Each variable has a set of attributes, covered in detail in section 5.2:
| Attribute | Options |
|---|---|
| Type | Variable (environment variable) or File |
| Environment scope | All (*), a specific environment, or a wildcard such as review/* |
| Visibility | Visible, Masked, Masked and hidden |
| Flags | Protect variable, Expand variable reference |
| Description | Free text explaining the variable's purpose |
Values are limited to 10,000 characters. The same operations are available through the REST API, which is how teams manage variables as code:
# Create a masked, protected project variable scoped to production
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12345/variables" \
--form "key=DEPLOY_TOKEN" \
--form "value=$NEW_DEPLOY_TOKEN" \
--form "masked=true" \
--form "protected=true" \
--form "environment_scope=production" \
--form "description=Token used by deploy jobs"
# Group variables use /groups/:id/variables, instance variables /admin/ci/variables
curl --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/groups/678/variables" | jq '.[].key'
Settings variables are not visible to developers in the repository. They can still be read by any job that receives them, which means by anyone who can change .gitlab-ci.yml on a branch that receives them. Protection (section 5.2) restricts which branches receive them.
Key takeaways
- Project, group and instance variables are stored in settings, not in the repository, and are inherited down the hierarchy.
- Define a value once at the highest scope that genuinely shares it, and no higher.
- Project variables need Maintainer, group variables need Owner, instance variables need an administrator.
- Anyone who can edit the pipeline on a branch can read every variable that branch receives.
Practice
- (Beginner) Create a group variable
COMPANY_NAMEand a project variableTEAM_NAMEin one of the group's projects. Print both from a job.- (Intermediate) Write a script that reads a local
vars.jsonfile (key, value, protected, masked, scope) and creates or updates project variables through the API. Make it idempotent: running it twice must not fail.- Interview question: "Forty projects need the same deploy credential. Where do you define it, and how do you limit the blast radius if one project's pipeline is compromised?" Hint: compare one group variable with per-project credentials, protected variables and environment scopes.
Variable Precedence
Why it matters. The same name is often defined in several places: a global default in the YAML, an override in project settings, a value typed into the Run pipeline form. When jobs behave differently than the YAML suggests, the reason is almost always precedence. Knowing the order lets you predict the value instead of guessing.
What it is. When several sources define the same variable, GitLab uses the value from the source with the highest precedence. From highest to lowest:
| Rank | Source |
|---|---|
| 1 | Variables from pipeline execution policies and scan execution policies (only for jobs the policy adds) |
| 2 | Manual job variables, entered when running a manual job |
| 3 | Pipeline variables: Run pipeline form, schedules, Pipelines and Triggers APIs, the ci.variable push option, variables passed from an upstream pipeline |
| 4 | Project variables |
| 5 | Group variables (the closest subgroup wins among nested groups) |
| 6 | Instance variables |
| 7 | Variables from dotenv reports of jobs in needs or dependencies |
| 8 | .gitlab-ci.yml variables: rules:variables, then job variables, then workflow:rules:variables, then top-level variables |
| 9 | Deployment variables |
| 10 | Predefined variables |
Inputs (section 5.3) are not variables and are not part of this order.
An analogy: the YAML is the printed schedule, project settings are the notice pinned on the board, and pipeline variables are the announcement made over the loudspeaker today. The most immediate source wins.
variables:
LOG_LEVEL: "info" # rank 8 (top-level)
test:
variables:
LOG_LEVEL: "debug" # rank 8 (job), beats top-level
script:
- echo "LOG_LEVEL=$LOG_LEVEL"
Defined: test prints:
YAML only LOG_LEVEL=debug
+ project variable LOG_LEVEL=warn LOG_LEVEL=warn (settings beat YAML)
+ Run pipeline with LOG_LEVEL=trace LOG_LEVEL=trace (pipeline variable beats settings)
Two consequences cause most surprises:
- A project variable overrides YAML, even job-level YAML. A forgotten project variable can override a carefully reviewed YAML change.
- Pipeline variables override almost everything, including project variables that hold configuration. That is why the Minimum role to use pipeline variables setting (Settings > CI/CD > Variables) exists. Its options are
no_one_allowed,owner,maintaineranddeveloper.no_one_allowedis the default for new projects in new namespaces on GitLab.com, andmaintaineron self-managed. Users below the minimum get "Insufficient permissions to set pipeline variables".
Key takeaways
- Policies and manual job variables rank highest, followed by pipeline variables and then settings variables (project, group, instance).
- Settings variables override every YAML variable.
dotenvvariables override YAML variables but not settings. - Within the YAML:
rules:variables> jobvariables>workflow:rules:variables> top-levelvariables. - Restrict pipeline variables with the minimum role setting, and prefer typed inputs for user-supplied values.
Practice
- (Beginner) Define
COLORat top level, at job level and as a project variable. Predict the printed value, run the job, then delete the project variable and run again.- (Intermediate) Create a
dotenvreport that setsCOLORin a build job and have a test jobneedsit. Determine experimentally wheredotenvsits relative to job-level YAML and project variables.- Interview question: "After a merge, a job uses
TIMEOUT=30even though the reviewed YAML saysTIMEOUT: \"300\". Nobody ran the pipeline manually. Where would you look?" Hint: list every source above the YAML in the precedence order and check each one, including groups higher up.
Variable Expansion
Why it matters. Variables often reference other variables: IMAGE_TAG: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA". Such a reference can be expanded at three different points, by three different programs with different rules. Knowing which one handles a keyword explains why ${VAR} works in one place, %VAR% in another, and why some references stay unexpanded.
What it is. "Expansion" means replacing $NAME with its value. There are three mechanisms:
| Mechanism | When | Syntax supported | Keywords |
|---|---|---|---|
| GitLab | Pipeline creation, before the runner gets the job | $VAR, ${VAR}, %VAR%, on every OS. Nested references expanded recursively | environment:name, environment:url, include, tags, rules:changes, rules:exists, variables (first pass) |
| Runner | When the runner prepares the job | $VAR, ${VAR}. One pass only | image, services:name, cache:key, artifacts:paths, variables (second pass for what GitLab could not expand) |
| Shell | While script runs | Whatever the shell supports: $VAR in bash/sh, $env:VAR in PowerShell, %VAR% in cmd | before_script, script, after_script |
rules:if is special: it compares variables in its own expression language and must use the $VAR form.
.gitlab-ci.yml GitLab Runner Shell
environment: review/$CI_COMMIT_REF_SLUG ──> review/feature-x
image: node:$NODE_VERSION (left for runner) ─────────> node:20.15
script: echo "$CI_JOB_ID" (passed through) ──────────> (passed through) ─────────> echo 554433
variables:
BASE_URL: "https://api.example.com"
HEALTH_URL: "${BASE_URL}/health" # expanded by GitLab: refers to another YAML variable
PRICE: "costs $$5" # $$ escapes: the value is "costs $5"
deploy-review:
environment:
name: review/$CI_COMMIT_REF_SLUG # GitLab expansion
url: https://$CI_COMMIT_REF_SLUG.review.example.com
image: alpine:$ALPINE_VERSION # runner expansion (project variable ALPINE_VERSION)
script:
- curl -fsS "$HEALTH_URL" # shell sees the already expanded value
- BUILD_STAMP=$(date +%s) # shell variable, exists only in this script
- echo "built at $BUILD_STAMP"
artifacts:
paths: ["out/$BUILD_STAMP/"] # does NOT work: script-defined variables never reach keywords
Some job-level predefined variables, such as CI_JOB_ID, CI_JOB_TOKEN and CI_REGISTRY_PASSWORD, are persisted variables. They are known only when the job starts. They expand in runner- and shell-expanded places, but not in GitLab-expanded keywords or in rules. In environment:name, for example, $CI_JOB_ID would not be expanded.
To use a value computed in script in a keyword such as artifacts:paths or cache:key, write it to the file named by $GITLAB_ENV (supported by recent runners for these keywords), or to a dotenv report for later jobs (section 5.3).
Key takeaways
- GitLab, the runner and the shell expand variables at different times with different syntax.
- GitLab-expanded keywords (
environment,include,tags) cannot use job-only (persisted) variables. - Runner expansion runs a single pass, so deeply nested references can stay unexpanded.
- Variables created in
scriptdo not reach keywords. Use$GITLAB_ENVordotenvreports for that. - Write
$$for a literal dollar sign in YAML values.
Practice
- (Beginner) Define
A: "one",B: "$A-two"andC: "$B-three"as YAML variables and printC. Then defineBas a project variable instead and compare the output.- (Intermediate) Build an
imagereference from two project variables (REGISTRY_HOSTandBUILDER_TAG) and anenvironment:namefromCI_COMMIT_REF_SLUG. Then try$CI_JOB_IDinenvironment:nameand explain the result using the mechanism table.- Interview question: "A Windows job uses
%BUILD_DIR%successfully inenvironment:url, but the same syntax fails inimage. Why?" Hint: compare the syntaxes supported by GitLab's expansion and the runner's expansion.
<a id="52-variable-types-and-protection"></a>
5.2 Variable Types and Protection
Settings variables carry attributes that change how they are delivered and who can see them. This subchapter covers file-type variables, the three visibility levels (visible, masked, hidden), protected and environment-scoped variables, and the raw versus expanded behavior.
File-Type Variables
Why it matters. Many tools do not read credentials from an environment variable. They expect a path to a file: kubectl --kubeconfig, gcloud auth activate-service-account --key-file, curl --cacert, SSH with -i. Writing a multi-line secret to disk by hand with echo is clumsy and error-prone, because quoting and newlines get mangled. File-type variables do this for you.
What it is. A file-type variable has a key, a value and a file. When the job starts, the runner writes the value to a temporary file inside the job's build directory, and the environment variable named by the key contains the path to that file, not its content. The file is removed when the job finishes.
Settings: KUBECONFIG_PROD (type File) = "apiVersion: v1\nclusters: ..."
In the job:
$KUBECONFIG_PROD -> /builds/acme/shop.tmp/KUBECONFIG_PROD (a path)
cat "$KUBECONFIG_PROD" -> apiVersion: v1 ... (the content)
deploy:
image: bitnami/kubectl:1.30
script:
# kubectl expects a path, and the file variable already is one
- kubectl --kubeconfig "$KUBECONFIG_PROD" get nodes
# Many tools read KUBECONFIG directly: point it at the file
- export KUBECONFIG="$KUBECONFIG_PROD"
- kubectl apply -f k8s/
call-internal-api:
script:
# INTERNAL_CA_CERT is a File variable containing a PEM certificate
- curl --cacert "$INTERNAL_CA_CERT" https://internal.example.com/health
Rules and caveats:
| Topic | Behavior |
|---|---|
| Where they can be defined | Project, group and instance settings, or the API (variable_type=file). Not in .gitlab-ci.yml |
| Assigning to another variable | KUBECONFIG: $KUBECONFIG_PROD in YAML passes the path, which is usually what you want |
| Need the content, not the path? | Read it: TOKEN="$(cat "$MY_FILE_VAR")" |
| YAML-only workaround | Write the content to a file in a script step, for example printf '%s' "$CERT" > ca.pem |
| Docker services | The path exists only in the job container, not inside service containers |
# Create a file-type variable from a local kubeconfig through the API
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12345/variables" \
--form "key=KUBECONFIG_PROD" \
--form "variable_type=file" \
--form "value=<~/.kube/prod-config" \
--form "protected=true" # the < prefix makes curl send the file's content as the value
Key takeaways
- A file-type variable delivers a path to a temporary file holding the value.
- Use them for tools that take a file: kubeconfigs, certificates, service account keys, SSH keys.
- They can be defined only in settings or the API, not in the YAML.
- Use
catwhen you need the content, and never print the file in the log.
Practice
- (Beginner) Create a file-type variable
SAMPLE_CONFIGwith three lines of text. In a job, print the variable, then printwc -l "$SAMPLE_CONFIG", and explain the difference.- (Intermediate) Store an SSH private key as a file-type variable and use it to
sshinto a test host with strict host key checking enabled. Pay attention to the file permissions SSH requires.- Interview question: "Why is a file-type variable usually safer and more reliable than
echo \"$KEY\" > key.pemwith an ordinary variable?" Hint: think about quoting, trailing newlines, shell history and accidentalset -xoutput.
Masked Variables
Why it matters. Job logs are widely visible: every project member with access to pipelines can read them, and in public projects possibly anyone. One debugging echo $TOKEN or a verbose tool printing its configuration can leak a credential to all of them. Masking is a safety net that replaces known secret values in the log.
What it is. When a variable is masked, the runner scans the job log and replaces every occurrence of the value with [MASKED]. The value still reaches the job unchanged. Only the log output is filtered.
A value can be masked only if it meets these requirements:
- A single line, with no spaces.
- At least 8 characters.
- It does not match the name of an existing predefined or custom variable.
- If "Expand variable reference" is enabled, the only allowed non-alphanumeric characters are
_ : @ - + . ~ = /. With expansion disabled, any character can be used.
GitLab rejects the masked setting if the value does not qualify.
debug-oops:
script:
- echo "Token is $API_TOKEN" # log shows: Token is [MASKED]
- echo "$API_TOKEN" | base64 # log shows the base64 text: NOT masked
- echo "${API_TOKEN:0:4}" # first 4 characters: NOT masked
- curl -v -H "Authorization: Bearer $API_TOKEN" https://api.example.com # header masked, but avoid -v anyway
Masking is a filter on exact matches in the log. It is not encryption:
| Masking protects against | Masking does not protect against |
|---|---|
| Accidentally echoing the value | Transformed values (base64, URL-encoded, split, reversed, escaped) |
| Tools printing the value verbatim | Writing the value into artifacts, caches or images |
| Sending the value to an external server from a malicious script | |
| Anyone who can edit the pipeline and exfiltrate it deliberately |
Analogy: masking is a censor reading the transcript after the fact and blacking out
one exact phrase. Say the phrase in another language (base64) and the censor misses it.
Key takeaways
- Masking replaces exact occurrences of a value with
[MASKED]in job logs only. - Masked values must be single-line, at least 8 characters and use a restricted character set when expansion is enabled.
- Transformed values, artifacts and deliberate exfiltration are not covered.
- Mask every secret, and still treat masking as a last line of defense, not access control.
Practice
- (Beginner) Create a masked variable and try to print it with
echo,printenv,base64andrev. Record which outputs are masked.- (Intermediate) A secret contains a space and cannot be masked. Propose two ways to store it so that it can be masked anyway, and implement one.
- Interview question: "A security review says: 'All our secrets are masked, so they cannot leak from CI.' What is wrong with this statement?" Hint: list who can modify
.gitlab-ci.ymland what a job can do with a value besides printing it.
Hidden Variables
Why it matters. Masking protects logs, but project Maintainers can still open Settings > CI/CD > Variables and reveal a masked value in the UI. For high-value credentials, you may want a value that nobody can read back once saved: it can be used by pipelines and replaced, but never viewed.
What it is. The visibility option Masked and hidden (GitLab 17.4, generally available in 17.6) masks the value in job logs and prevents it from ever being shown in the settings UI or returned by the API after saving.
| Visibility | Shown in UI after saving | Masked in job logs |
|---|---|---|
| Visible | Yes | No |
| Masked | Yes, to users who can manage variables | Yes |
| Masked and hidden | Never | Yes |
Rules:
- A variable can only be made hidden when it is created. An existing variable cannot be switched to hidden. Delete and recreate it instead.
- The value must meet the masking requirements.
- Hidden variables cannot use variable expansion.
- To change the value, you overwrite it. You never see the old one.
- Instance-level variables gained the "Masked and hidden" option in GitLab 19.0.
# Create a hidden variable through the API
curl --request POST --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/12345/variables" \
--form "key=SIGNING_KEY" \
--form "value=$SIGNING_KEY_VALUE" \
--form "masked_and_hidden=true" \
--form "protected=true"
Hiding protects against reading the value in GitLab's settings. A job that receives the variable can still use it, and therefore still misuse it. Combine hiding with protection and environment scopes.
Key takeaways
- "Masked and hidden" values cannot be viewed in the UI or API after saving, only replaced.
- Hiding is chosen at creation time and cannot be added to an existing variable.
- Hidden values must satisfy the masking rules and cannot use expansion.
- Hidden does not stop pipelines from using the value, so combine it with protection.
Practice
- (Beginner) Create a hidden variable and confirm that the UI no longer offers to reveal the value. Then use it in a job and confirm the log shows
[MASKED].- (Intermediate) Convert an existing masked variable to a hidden one without breaking running pipelines. Write down the exact steps and the order in which you perform them.
- Interview question: "If hidden variables cannot be read in the UI, why do you still need protected branches for them?" Hint: consider who can write a job that runs
curlwith the variable's value.
Protected Variables
Why it matters. Every developer can push a branch and change .gitlab-ci.yml on it. If production credentials reached every branch, any developer, or any compromised developer account, could write a job that sends them elsewhere. Protected variables tie secrets to the branches that already require review before anything lands there.
What it is. A variable with Protect variable set is passed only to pipelines running on a protected branch or protected tag. On any other ref the variable simply does not exist. The job does not fail. It sees an empty value.
Protected branches: main, release/* Protected tags: v*
Pipeline on main -> PROD_DEPLOY_TOKEN available
Pipeline on release/2.4 -> available (wildcard protected branch)
Pipeline on tag v2.4.0 -> available (protected tag)
Pipeline on feature/login -> not available (empty)
MR pipeline from a fork -> not available, never
deploy-production:
stage: deploy
script:
# Fail fast with a clear message instead of a confusing auth error later
- '[ -n "$PROD_DEPLOY_TOKEN" ] || { echo "PROD_DEPLOY_TOKEN missing: is this ref protected?"; exit 1; }'
- ./deploy.sh --token-file <(printf '%s' "$PROD_DEPLOY_TOKEN")
environment: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Merge request pipelines. By default, MR pipelines run for the source branch, which is usually unprotected, so they get no protected variables. Since GitLab 18.1, the project setting Allow merge request pipelines to access protected variables and runners (Settings > CI/CD > Variables) allows it only when:
- both source and target branches are protected,
- the user who starts the pipeline can push or merge to the target branch, and
- both branches are in the same project (forks never get access).
Protection is only as strong as the protection of the branches:
| Weakness | Consequence |
|---|---|
Developers allowed to push to main | Any developer can change the pipeline on main and read protected variables |
Wildcard such as * protected | Every branch counts as protected |
Developers allowed to create protected tags v* | A tag can be pushed from any commit, including unreviewed ones |
Key takeaways
- Protected variables are passed only to pipelines on protected branches and protected tags.
- On unprotected refs the variable is empty and the job is not stopped, so check for it explicitly.
- MR pipelines get protected variables only through an opt-in setting with strict conditions, and fork MRs never do.
- The security of protected variables equals the security of your branch and tag protection rules.
Practice
- (Beginner) Create a protected variable and print its length (
echo ${#VAR}) in a job. Run the job onmainand on a feature branch and compare.- (Intermediate) Protect tags matching
v*so that only Maintainers can create them. Verify that a Developer's tag pipeline cannot read the release signing key.- Interview question: "Your production secret is protected, yet an engineer extracted it from CI without Maintainer rights. Name three ways this could have happened." Hint: look at branch protection settings, tag protection and the MR pipeline access setting.
Environment-Scoped Variables
Why it matters. Staging and production usually need the same variable names with different values: DATABASE_URL, API_KEY, REPLICAS. Different names (STAGING_DATABASE_URL, PROD_DATABASE_URL) lead to duplicated scripts and to mistakes such as a staging job reading a production credential. Environment scopes let one name carry different values per environment, and keep each value away from jobs deploying elsewhere.
What it is. Each settings variable has an environment scope. A job receives the variable only if the job declares an environment:name that matches the scope.
| Scope | Matches environment names |
|---|---|
* (default) | All, including jobs without an environment |
production | Exactly production |
review/* | review/feature-x, review/fix-123, ... |
staging-* | staging-eu, staging-us |
When the same key exists with several matching scopes, the most specific scope wins: an exact name beats a wildcard pattern, which beats *. Project-level scopes are available in all tiers. Group-level scopes require Premium or Ultimate.
Settings:
DATABASE_URL scope * -> postgres://dev-db/app
DATABASE_URL scope staging -> postgres://staging-db/app
DATABASE_URL scope production -> postgres://prod-db/app (protected, masked)
.deploy:
script:
- ./migrate.sh "$DATABASE_URL" # same name, value depends on the environment
- ./deploy.sh
deploy-staging:
extends: .deploy
environment: staging # receives the staging value
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-production:
extends: .deploy
environment: production # receives the production value
rules:
- if: $CI_COMMIT_TAG
when: manual
unit-tests:
script: [./test.sh] # no environment: only the "*" value, never production's
Environment scopes combine well with protection and with protected environments (chapter 12): the variable is available only to jobs that deploy to production, on protected refs, and the deployment itself can require approval.
Key takeaways
- An environment scope restricts a variable to jobs whose
environment:namematches. - One variable name can carry different values per environment, which keeps scripts identical.
- The most specific matching scope wins. Jobs without an environment receive only
*-scoped values. - Combine scope, protection and protected environments for production credentials.
Practice
- (Beginner) Create
GREETINGwith scopes*,stagingandproduction. Write three jobs (no environment, staging, production) that print it.- (Intermediate) Configure review apps with environment
review/$CI_COMMIT_REF_SLUGand a variable scoped toreview/*. Confirm that a production-scoped secret is unavailable in review jobs.- Interview question: "Why is an environment-scoped production variable safer than a variable named
PROD_DB_PASSWORDwith scope*, even if both are protected?" Hint: count the jobs onmainthat can read each one.
Raw vs Expanded Values
Why it matters. Generated passwords often contain $. If GitLab treats pa$sW0rd! as "pa followed by the variable $sW0rd", the job receives pa! and authentication fails with no obvious reason. Some values must be passed exactly as written.
What it is. A variable is either expanded ($NAME inside the value is replaced by that variable's value) or raw (the value is used literally).
| Where defined | How to make it raw | Default |
|---|---|---|
| Settings UI | Clear Expand variable reference | Not expanded in current versions (cleared by default since GitLab 18.6) |
| API | raw=true | raw defaults to false (expanded) when omitted |
.gitlab-ci.yml | expand: false in the long form | Expanded |
.gitlab-ci.yml, single $ | Escape with $$ |
variables:
GREETING_TEMPLATE:
value: "Hello $USER, your build is $STATUS"
expand: false # GitLab and the runner leave $USER and $STATUS untouched
LITERAL_PRICE: "costs $$10" # escaping alternative: value becomes "costs $10"
render:
script:
- echo "$GREETING_TEMPLATE" # prints: Hello $USER, your build is $STATUS
- USER=ana STATUS=green envsubst <<< "$GREETING_TEMPLATE" # expand later, on purpose
Value stored: pa$sW0rd!
Expand variable reference ON -> job receives "pa!" ($sW0rd is undefined, so it becomes empty)
Expand variable reference OFF -> job receives "pa$sW0rd!"
The shell does not re-expand the contents of an environment variable. echo "$PASSWORD" prints the raw value, unless you pass it through eval or a template tool.
Raw values also matter for masking. With expansion off, any character can be masked, and masked or hidden variables cannot use expansion anyway. Keep secrets raw.
Key takeaways
- Expanded values replace
$NAMEreferences. Raw values are used exactly as written. - Clear "Expand variable reference" (UI), use
raw=true(API) orexpand: false(YAML) for literal values. - Secrets containing
$must be raw, or they are silently corrupted. $$escapes a single dollar sign in YAML values.
Practice
- (Beginner) Create two project variables with the value
abc$HOME, one expanded and one raw. Print both and explain the output.- (Intermediate) Audit a project's variables through the API (
GET /projects/:id/variables) and list every variable whose value contains$and hasraw: false.- Interview question: "A deploy started failing with 'invalid credentials' right after a password rotation, though the new password works locally. What would you check first?" Hint: look at the characters in the new password and at how the variable is configured.
<a id="53-dynamic-variables"></a>
5.3 Dynamic Variables
The variables so far are fixed before the pipeline starts. Real pipelines also produce values while they run (a computed version number, a deployed URL) or accept values from the person starting them. This subchapter covers dotenv reports, the options for passing data between jobs, prefilled variables in the Run pipeline form, and typed pipeline inputs.
dotenv Report Artifacts
Why it matters. Each job runs in its own shell, often on a different machine. A variable exported in one job's script is gone when that job ends. Yet later jobs often need values that only exist at runtime: the version computed from git tags, the ID of a created resource, the URL of a freshly deployed review app. dotenv reports carry such values forward as real CI/CD variables.
What it is. A job writes KEY=VALUE lines to a file and declares that file under artifacts:reports:dotenv. GitLab parses it after the job finishes, and the variables become available to later jobs that download the job's artifacts.
stages: [build, test, deploy]
version:
stage: build
script:
- VERSION="$(git describe --tags --always)" # computed at runtime
- echo "APP_VERSION=$VERSION" >> build.env # one KEY=VALUE per line
- echo "BUILD_TIME=$(date -u +%Y%m%dT%H%M%SZ)" >> build.env
artifacts:
reports:
dotenv: build.env # turns the lines into variables
test:
stage: test
script:
- echo "Testing $APP_VERSION built at $BUILD_TIME" # available automatically (later stage)
The file format is strict:
| Rule | Example |
|---|---|
One KEY=VALUE per line | APP_VERSION=1.4.2 |
| Names: ASCII letters, digits, underscores | IMAGE_DIGEST |
No empty lines, no # comments | |
| No quoting. Quotes are kept as literal characters | A="x" gives the value "x", with quotes |
| No multi-line values, no values that need escaping | Put JSON in a regular artifact instead |
| Leading and trailing whitespace is stripped | |
| UTF-8 only, at most 5 KB |
There is also a limit on the number of inherited variables (20 by default on self-managed, configurable by administrators, with plan-based limits on GitLab.com).
Who receives the variables:
version (build) ──dotenv──> every job in later stages (default)
└─> jobs with needs: [version] (needs with artifacts: true, the default)
└─> NOT jobs with dependencies: [] (no artifacts downloaded)
└─> NOT jobs in the same stage, unless they use needs
└─> NOT rules:if anywhere (rules run before any job)
dotenv variables rank above YAML variables but below settings and pipeline variables (section 5.1). If a name appears twice in a file, the last value is used.
A special case: a deployment job can set its own environment URL from a dotenv report, which is how review apps on platforms that generate random URLs get a working link.
deploy-review:
stage: deploy
script:
- URL="$(./deploy-review.sh)" # platform returns a generated URL
- echo "DYNAMIC_ENVIRONMENT_URL=$URL" >> deploy.env
artifacts:
reports:
dotenv: deploy.env
environment:
name: review/$CI_COMMIT_REF_SLUG
url: $DYNAMIC_ENVIRONMENT_URL # filled in after the job finishes
Key takeaways
- A
dotenvreport turnsKEY=VALUElines into CI/CD variables for later jobs. - The format is strict: no comments, no blank lines, no quoting, no multi-line values, at most 5 KB.
- Artifact download rules (
stages,needs,dependencies) decide which jobs receive the variables. dotenvvariables are never available inrules, because rules are evaluated before any job runs.
Practice
- (Beginner) Compute
SHORT_DATE=$(date +%F)in a build job, pass it through adotenvreport and print it in a test job.- (Intermediate) Write a
dotenvfile containing a blank line, a comment and a quoted value. Run the job, observe the result, then fix the file. Separately, show thatdependencies: []prevents a job from receiving the variables.- Interview question: "A job in the
deploystage hasrules: - if: $APP_VERSION =~ /-rc/whereAPP_VERSIONcomes from adotenvreport, and the job never runs. Why, and how would you restructure it?" Hint: compare pipeline creation time with job run time. Consider making the decision in a script or in a child pipeline.
Passing Variables Between Jobs
Why it matters. "How do I get this value from job A to job B?" is one of the most common CI questions, and the right answer depends on the shape of the data and on where B runs: same pipeline, child pipeline or another project. Picking the wrong mechanism leads to fragile workarounds such as committing files back to the repository.
What it is. Jobs share nothing by default (section 2.2). Data moves only through mechanisms GitLab provides:
| Scenario | Mechanism | Notes |
|---|---|---|
| Small key/value pairs to later jobs, same pipeline | artifacts:reports:dotenv | Values become CI/CD variables |
| Files or structured data (JSON, binaries), same pipeline | artifacts:paths + needs/dependencies | Parse the file in the receiving job |
| Static values into a child pipeline | Trigger job variables, or include:inputs | Section 3.3 |
| Runtime values into a child pipeline | dotenv in the parent + needs:pipeline:job in the child | Section 3.3 |
| Values into another project's pipeline | Trigger job variables or trigger:inputs | Subject to the downstream pipeline-variable restriction |
| Runtime values from another project | needs:project with a dotenv artifact | Premium, section 3.4 |
| Values within one job | Plain shell variables, before_script to script | Not into after_script |
stages: [build, test, deploy]
build:
stage: build
script:
- make build
- echo "IMAGE_DIGEST=$(cat out/digest.txt)" >> vars.env # small value -> dotenv
- ./generate-manifest.sh > out/manifest.json # structured data -> file
artifacts:
paths: [out/manifest.json]
reports:
dotenv: vars.env
scan:
stage: test
needs: [build] # gets IMAGE_DIGEST and manifest.json
script:
- trivy image "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
- jq -r '.services[].name' out/manifest.json # read structured data from the file
notify:
stage: deploy
needs:
- job: build
artifacts: false # blocks ALL artifacts, including dotenv
script:
- echo "digest is '${IMAGE_DIGEST:-unset}'" # prints: digest is 'unset'
Same pipeline Parent ─> child Project A ─> project B
build ──dotenv/artifacts──> parent job ──trigger vars──> trigger job ──vars/inputs──> B pipeline
test child job <──needs:pipeline:job B job <──needs:project── A job (Premium)
Avoid the anti-patterns: writing values to the cache (not guaranteed to exist), committing files back to the repository (triggers new pipelines and pollutes history), or calling the variables API to store runtime state (needs a powerful token and races between pipelines).
Key takeaways
- Small values go through
dotenvreports, files and structured data through regular artifacts. needsanddependenciesdecide which jobs receive both kinds, andartifacts: falseblocks both.- Child and downstream pipelines receive static values through trigger variables or inputs, and runtime values through
needs:pipeline:joborneeds:project. - Do not use caches, commits or the variables API as a message bus.
Practice
- (Beginner) Pass a build number and a JSON file listing built binaries from a
buildjob to apackagejob. Use the appropriate mechanism for each.- (Intermediate) In a parent-child setup, compute
RELEASE_IDin the parent at runtime and use it in a child job without hard-coding the parent pipeline ID.- Interview question: "A teammate stores the deployed version in a project variable by calling the API from the deploy job, so that the next pipeline can read it. What risks does this introduce, and what would you use instead?" Hint: think about token scope, concurrent pipelines, precedence of project variables and the environments and deployments API.
Prefilled Variables for Manual Pipelines
Why it matters. Pipelines started by hand from Build > Pipelines > Run pipeline often need parameters: which environment, which log level, whether to run slow tests. Without guidance, users have to know the variable names by heart and type them without typos. Prefilled variables turn the form into a documented, partly constrained input screen.
What it is. Top-level variables written in the long form with a description appear pre-populated in the Run pipeline form. value sets the default, and options turns the field into a drop-down list (the value must be one of the options).
variables:
DEPLOY_ENV:
description: "Target environment. Only used by manual runs."
value: "staging" # default shown in the form
options: ["staging", "production"] # drop-down; value must be in the list
LOG_LEVEL:
description: "Verbosity of deployment scripts."
value: "info" # free text with a default
RUN_E2E:
description: "Set to true to run the slow end-to-end suite."
value: "false" # strings, not booleans
e2e:
script: [./run-e2e.sh]
rules:
- if: $RUN_E2E == "true"
Run pipeline
Branch: [ main v]
DEPLOY_ENV [ staging v] Target environment. Only used by manual runs.
LOG_LEVEL [ info ] Verbosity of deployment scripts.
RUN_E2E [ false ] Set to true to run the slow end-to-end suite.
[ + Add variable ] [ Run pipeline ]
How it behaves:
- On push, schedule and MR pipelines, the YAML
valueis used as a normal top-level variable. - When a user changes a value in the form, it is submitted as a pipeline variable (high precedence, section 5.1). The minimum-role setting for pipeline variables applies.
optionsis enforced only in the form. The API and trigger tokens can send any value.- Everything is a string, so there is no type checking.
These limitations are why GitLab recommends pipeline inputs (next topic) for new parameterized pipelines.
Key takeaways
- Top-level variables with
description(and optionallyvalueandoptions) prefill the Run pipeline form. optionscreates a drop-down list, but other entry points can still send any value.- Changed values become pipeline variables, subject to the pipeline-variable role restriction.
- Values are untyped strings. Prefer inputs for new parameterized pipelines.
Practice
- (Beginner) Add a prefilled
REGIONvariable with optionseu-west-1andus-east-1and a description. Run the pipeline manually with each value.- (Intermediate) Trigger the same pipeline through the API with
REGION=ap-south-1. Observe that the value is accepted, then add a validation job in the.prestage that fails for values outside the allowed list.- Interview question: "Why are prefilled variables with
optionsnot a security control?" Hint: list every way a pipeline variable can be set besides the Run pipeline form.
Pipeline Inputs
Why it matters. Variables are untyped, can be overridden from many places and leak into every job as environment variables. For parameters that callers pass in ("deploy to which environment, with how many replicas?"), you want something stricter: declared up front, typed, validated and immutable once the pipeline starts. That is what inputs provide. Chapter 9 covers inputs for reusable components; this topic covers inputs for whole pipelines.
What it is. Since GitLab 17.11, the main .gitlab-ci.yml can declare inputs in a spec:inputs header, separated from the rest of the file by ---. Inputs are interpolated with $[[ inputs.name ]] while GitLab builds the configuration, before rules are evaluated and before any job runs. They are not CI/CD variables and do not appear in the precedence order.
spec:
inputs:
environment:
type: string # string (default), number, boolean or array
default: staging # no default = required input
options: [staging, production] # validated on every entry point, not just the form
description: Target environment
replicas:
type: number
default: 2
run_e2e:
type: boolean
default: false
version:
default: latest
regex: ^(latest|v\d+\.\d+\.\d+)$ # value must match
---
deploy:
stage: deploy
environment: $[[ inputs.environment ]] # interpolated at configuration time
script:
- ./deploy.sh --version "$[[ inputs.version ]]" --replicas $[[ inputs.replicas ]]
e2e:
stage: test
variables:
RUN_E2E: "$[[ inputs.run_e2e ]]" # bridge an input into a variable for rules
rules:
- if: $RUN_E2E == "true"
script: [./run-e2e.sh]
Ways to pass inputs:
| Entry point | How |
|---|---|
| Run pipeline form | Each input is shown with its type, default, description and options |
| Pipelines API | inputs object in the request body |
| Pipeline triggers API | Inputs alongside the trigger token |
| Pipeline schedules | Inputs stored on the schedule |
| Git push options | Inputs on push |
| Downstream pipelines | trigger:inputs, or include:inputs for child pipelines |
# Start a pipeline on main with typed inputs (numbers and booleans stay typed)
curl --request POST \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data '{"ref": "main", "inputs": {"environment": "production", "replicas": 4, "run_e2e": true}}' \
"https://gitlab.example.com/api/v4/projects/12345/pipeline"
# Upstream project: start the deployment project's pipeline with inputs
deploy-shop:
trigger:
project: ops/deploy
inputs:
environment: production
replicas: 6
Interpolation can be modified with functions (up to three per block): expand_vars expands CI/CD variables inside the input value, truncate(offset,length) shortens it, and posix_escape escapes shell metacharacters (not a security control for untrusted values). Example: $[[ inputs.name | expand_vars | truncate(0,20) ]].
| Variables (prefilled) | Inputs | |
|---|---|---|
| Types | Strings only | string, number, boolean, array |
| Validation | options in the form only | options and regex on every entry point |
| Required values | Not possible | Omit default |
| Override after start | Higher-precedence sources can override | Fixed at configuration time |
| Scope | Environment variable in every job | Only where interpolated |
Usable in rules:if directly | Yes | Through interpolation or a bridging variable |
Limits: up to 20 inputs per pipeline. Give every input a default if the pipeline also runs automatically, because a missing required input makes automatic pipelines fail.
Key takeaways
- Pipeline inputs are declared in
spec:inputsabove---in the main.gitlab-ci.ymland used with$[[ inputs.name ]]. - They are typed, validated with
optionsandregex, and fixed when the configuration is built. - They can be passed from the UI, API, triggers, schedules, push options and upstream pipelines.
- Prefer inputs over pipeline variables for user-supplied parameters, and give them defaults when pipelines also run on push.
Practice
- (Beginner) Convert the prefilled
DEPLOY_ENVvariable from the previous topic into a string input with options. Run the pipeline manually and through the API with an invalid value, and compare the errors.- (Intermediate) Add a
servicesarray input with default["api", "web"]and use it withparallel:matrixto create one deploy job per service. Pass a different array through the API.- Interview question: "Why are pipeline inputs considered safer than pipeline variables for a deployment pipeline that other teams trigger?" Hint: compare typing, validation, precedence and how far each value spreads into jobs.
<a id="54-secrets-management"></a>
5.4 Secrets Management
CI/CD variables store secrets inside GitLab as long-lived values copied into settings. Dedicated secret stores add rotation, fine-grained policies, audit logs and short-lived credentials. This subchapter shows how jobs prove their identity with ID tokens, and how they use that identity to fetch secrets from HashiCorp Vault, AWS, Azure, Google Cloud and GitLab's own secrets manager.
ID Tokens and OIDC Authentication
Why it matters. The classic approach puts a cloud access key into a masked, protected variable. That key never expires, works from anywhere, is often over-privileged and has to be rotated by hand. If it leaks, the attacker keeps access until someone notices. A better model is that the job proves who it is (this project, this branch, this environment), and the cloud provider issues a short-lived credential based on that proof. No long-lived secret is stored in GitLab at all.
What it is. The id_tokens keyword makes GitLab issue one or more JSON Web Tokens (JWTs) for a job, signed by GitLab and exposed as variables. The token follows OpenID Connect (OIDC) conventions. A cloud provider or Vault that trusts your GitLab instance as an identity provider verifies the signature using GitLab's public keys, checks the claims against its policy, and returns temporary credentials.
Think of a hotel key card issued at reception: it is printed for one guest, one room and one stay, and it stops working on checkout. A long-lived access key is a master key someone copied.
1. Job starts GitLab ── issues signed JWT (aud, sub, project_path, ref, environment...) ──> job
2. Job presents job ── JWT ──> AWS STS / Vault / Azure AD / GCP STS
3. Provider checks signature (keys from https://gitlab.example.com/oauth/discovery/keys)
+ issuer + audience + claims match the trust policy
4. Provider grants short-lived credentials ──> job (expire minutes to an hour later)
deploy:
image:
name: amazon/aws-cli:2.17.0
entrypoint: [""] # the image's default entrypoint is the aws binary
id_tokens:
AWS_OIDC_TOKEN: # variable name chosen by you
aud: https://gitlab.example.com # audience: must match the provider's configuration
variables:
AWS_ROLE_ARN: arn:aws:iam::123456789012:role/gitlab-deploy-shop
AWS_WEB_IDENTITY_TOKEN_FILE: /tmp/web-identity-token
script:
- printf '%s' "$AWS_OIDC_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE" # AWS SDKs and CLI read the token file
- aws sts get-caller-identity # temporary credentials, no access keys stored
- aws s3 sync dist/ s3://shop-prod-assets/
environment: production
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Important claims in the token, which trust policies match on:
| Claim | Example | Use in policies |
|---|---|---|
iss | https://gitlab.example.com | Which GitLab issued it |
aud | https://gitlab.example.com | Who the token is meant for |
sub | project_path:acme/shop:ref_type:branch:ref:main | The main identity string |
project_id, project_path, namespace_path | 42, acme/shop, acme | Restrict to projects or groups |
ref, ref_type, ref_protected | main, branch, true | Restrict to protected refs |
environment, environment_protected | production, true | Restrict to protected environments |
pipeline_source, user_login, job_id | push, jdoe, 554433 | Auditing and finer rules |
The trust policy on the provider side is where security is decided. An AWS IAM role trust policy for the job above:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/gitlab.example.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"gitlab.example.com:aud": "https://gitlab.example.com",
"gitlab.example.com:sub": "project_path:acme/shop:ref_type:branch:ref:main"
}
}
}]
}
Notes:
- Tokens are short-lived and valid only around the job's run. Never log them: they are bearer credentials until they expire.
id_tokenscan be set indefaultto apply to every job, but issue tokens only to the jobs that need them.- The older
CI_JOB_JWTandCI_JOB_JWT_V2variables were removed in GitLab 17.0.id_tokensreplaces them. - A trust policy that matches only on
project_pathlets every branch of the project assume the role. Always include the ref, orref_protected/environment_protectedclaims where the provider supports them.
Key takeaways
id_tokensissues signed, short-lived OIDC JWTs whose claims describe the project, ref, environment and pipeline.- Cloud providers and Vault exchange them for temporary credentials, so no long-lived secret is stored in GitLab.
audmust match the provider's configuration. The provider's trust policy decides who gets access.- Bind trust policies to specific projects and protected refs or environments, never to the project alone.
Practice
- (Beginner) Add an
id_tokensentry to a job and print the decoded payload only (not the signature):echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null. Identifysub,refandref_protected.- (Intermediate) Configure an AWS IAM OIDC provider and a role that
maincan assume but feature branches cannot. Prove both outcomes withaws sts get-caller-identity.- Interview question: "Your trust policy uses
StringLikeonsubwithproject_path:acme/*. What could go wrong?" Hint: consider who can create projects underacmeand which refs are matched.
HashiCorp Vault Integration
Why it matters. Many organizations centralize secrets in HashiCorp Vault (or its open-source fork OpenBao) to get one place for policies, rotation, dynamic database credentials and audit logs. Pipelines should read from that source of truth instead of keeping copies in GitLab settings.
What it is. The secrets keyword (Premium and Ultimate) lets the runner fetch secrets from Vault before the job's script runs. Authentication uses an ID token and Vault's JWT auth method. By default each secret is exposed as a file-type variable (the variable holds a path). file: false exposes the value directly.
Runner ── VAULT_ID_TOKEN ──> Vault auth/jwt/login (role myproject-production)
<── Vault token (TTL 60s) ──
── read ops/data/production/db ──> field "password" ──> $DATABASE_PASSWORD
Vault side (one-time setup):
# 1. Trust GitLab as a JWT issuer
vault auth enable jwt
vault write auth/jwt/config \
oidc_discovery_url="https://gitlab.example.com" \
bound_issuer="https://gitlab.example.com"
# 2. A policy allowing read access to the production database secret (KV v2 adds /data/)
vault policy write myproject-production - <<'EOF'
path "ops/data/production/*" {
capabilities = ["read"]
}
EOF
# 3. A role that only protected main-branch jobs of project 42 can use
vault write auth/jwt/role/myproject-production - <<'EOF'
{
"role_type": "jwt",
"policies": ["myproject-production"],
"token_explicit_max_ttl": 60,
"user_claim": "user_email",
"bound_audiences": "https://vault.example.com",
"bound_claims_type": "glob",
"bound_claims": {
"project_id": "42",
"ref_protected": "true",
"ref_type": "branch",
"ref": "main"
}
}
EOF
GitLab side:
variables:
VAULT_SERVER_URL: https://vault.example.com:8200 # required (often a group variable)
VAULT_AUTH_ROLE: myproject-production # optional: role to log in with
VAULT_AUTH_PATH: jwt # optional: mount path of the JWT method (default jwt)
# VAULT_NAMESPACE: admin/payments # optional: Vault Enterprise namespace
deploy:
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com # must match bound_audiences
secrets:
DATABASE_PASSWORD:
vault: production/db/password@ops # <path>/<field>@<engine mount>: reads ops/data/production/db, field password
token: $VAULT_ID_TOKEN
file: false # value in the variable instead of a file path
TLS_CERT:
vault: # long form, for non-KV-v2 engines or clarity
engine:
name: kv-v2 # kv-v2 is the default engine
path: ops # where the engine is mounted
path: production/tls
field: certificate
token: $VAULT_ID_TOKEN # file-type by default: $TLS_CERT is a path
script:
- ./deploy.sh --db-password "$DATABASE_PASSWORD" --cert-file "$TLS_CERT"
environment: production
On the Free tier, or when you need Vault features the secrets keyword does not cover (dynamic database credentials, PKI), log in with the CLI yourself using the same ID token:
read-secret-manually:
image: hashicorp/vault:1.17
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
script:
- export VAULT_ADDR="$VAULT_SERVER_URL"
- export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=myproject-production jwt="$VAULT_ID_TOKEN")"
- export DB_CREDS="$(vault read -format=json database/creds/shop-readonly)" # dynamic, auto-expiring credentials
Key takeaways
- Vault trusts GitLab through the JWT auth method. Roles bind access to token claims such as
project_id,refandref_protected. secrets:vault(Premium) fetches values before the script runs. Short form:path/field@mount, withkv-v2as the default engine.- Secrets are file-type variables by default. Use
file: falsefor plain values. VAULT_SERVER_URLis required.VAULT_AUTH_ROLE,VAULT_AUTH_PATHandVAULT_NAMESPACEare optional.
Practice
- (Beginner) Run a Vault dev server, store
secret/ci/demowith fieldgreeting, configure JWT auth for your project and print the length of the fetched value in a job.- (Intermediate) Create two Vault roles,
stagingandproduction. Bind the production role toenvironment: productionwithenvironment_protected: true, and show that a staging job cannot read production secrets even onmain.- Interview question: "Why is
token_explicit_max_ttl: 60a reasonable setting for a CI role, and what problem would a 24-hour TTL create?" Hint: consider what a job could do with a leaked Vault token after the job ends.
AWS Secrets Manager Integration
Why it matters. Teams running on AWS often already keep database passwords and API keys in AWS Secrets Manager, with rotation handled by Lambda functions. Reading them directly from pipelines avoids a second copy in GitLab that would go stale after each rotation.
What it is. Since GitLab 18.3 (Premium and Ultimate), secrets supports aws_secrets_manager. The runner authenticates to AWS, either with OIDC through an ID token and AWS_ROLE_ARN, or with the IAM role attached to the runner itself (GitLab Runner 18.3 or later), and fetches the secret before the script runs.
variables:
AWS_REGION: eu-west-1 # region of the secrets
AWS_ROLE_ARN: arn:aws:iam::123456789012:role/gitlab-secrets-shop # role to assume via OIDC
deploy:
id_tokens:
AWS_ID_TOKEN: # default name the runner looks for
aud: sts.amazonaws.com
secrets:
DB_PASSWORD:
aws_secrets_manager:
secret_id: shop/production/database # name or ARN
field: password # key inside a JSON secret
file: false
API_KEY:
aws_secrets_manager: 'shop/production/api#api_key' # short form: <secret_id>#<field>
LEGACY_TOKEN:
aws_secrets_manager:
secret_id: arn:aws:secretsmanager:us-east-1:123456789012:secret:legacy-token-AbCdEf
version_stage: AWSPREVIOUS # or version_id (not both)
region: us-east-1 # per-secret override
script:
- ./deploy.sh
environment: production
| Option | Purpose |
|---|---|
secret_id | Secret name or ARN |
field | Key to extract from a JSON secret. Omit it to get the whole value |
version_id / version_stage | Pin a version or stage (AWSCURRENT, AWSPREVIOUS) |
region, role_arn, role_session_name | Per-secret overrides of AWS_REGION, AWS_ROLE_ARN, AWS_ROLE_SESSION_NAME |
token | Use an ID token not named AWS_ID_TOKEN |
On the AWS side, the role needs a trust policy for GitLab's OIDC provider (previous topic, with aud set to sts.amazonaws.com) and a permissions policy that allows secretsmanager:GetSecretValue on exactly the secrets the project needs. If the secrets use a customer-managed KMS key, it also needs kms:Decrypt on that key.
Key takeaways
secrets:aws_secrets_manager(Premium, GitLab 18.3+) fetches secrets by name or ARN, optionally a JSONfieldand a version.- Authentication is OIDC (
AWS_ID_TOKEN+AWS_ROLE_ARN) or the runner's own IAM role. - Scope the IAM role to specific secret ARNs and bind its trust policy to protected refs.
- Rotation in AWS is picked up automatically, because pipelines always read the current version.
Practice
- (Beginner) Create a JSON secret with
usernameandpasswordkeys and fetch onlypasswordin a job, using the short form.- (Intermediate) Configure two roles: one for
mainthat can readshop/production/*and one for any branch that can read onlyshop/dev/*. Use per-secretrole_arnoverrides and prove the separation.- Interview question: "When would you prefer the runner's IAM role over OIDC for fetching AWS secrets, and what is the security trade-off?" Hint: consider who shares the runner and whether the role can distinguish between projects and branches.
Azure Key Vault Integration
Why it matters. On Azure, Key Vault is the standard store for secrets, keys and certificates, with access governed by Microsoft Entra ID (formerly Azure AD) and RBAC. Pipelines deploying to Azure should read from it with federated identity rather than storing a client secret in GitLab.
What it is. secrets:azure_key_vault (Premium and Ultimate) fetches a secret from Key Vault. The job's ID token is exchanged for an Entra ID token through a federated identity credential on an app registration (or user-assigned managed identity).
variables:
AZURE_KEY_VAULT_SERVER_URL: https://shop-prod-kv.vault.azure.net # the vault
AZURE_CLIENT_ID: 00000000-1111-2222-3333-444444444444 # app registration / identity
AZURE_TENANT_ID: 55555555-6666-7777-8888-999999999999
deploy:
id_tokens:
AZURE_JWT:
aud: https://gitlab.com # must match the federated credential's audience
secrets:
DATABASE_PASSWORD:
token: $AZURE_JWT
azure_key_vault:
name: DATABASE-PASSWORD # secret name in Key Vault
version: 00000000000000000000000000000000 # optional: pin a specific version
script:
- ./deploy.sh
environment: production
Azure side, in summary:
App registration "gitlab-shop-deploy"
└── Federated credential
Issuer: https://gitlab.com (your GitLab URL)
Subject: project_path:acme/shop:ref_type:branch:ref:main (exact match)
Audience: https://gitlab.com
Key Vault "shop-prod-kv" (RBAC)
└── Role assignment: "Key Vault Secrets User" -> gitlab-shop-deploy
The federated credential matches the sub claim exactly, so each branch or tag that needs access requires its own credential. This is restrictive by design. Grant only Key Vault Secrets User (read secret values) to the pipeline's identity, not administrative roles.
Key takeaways
secrets:azure_key_vault(Premium) needsAZURE_KEY_VAULT_SERVER_URL,AZURE_CLIENT_IDandAZURE_TENANT_ID, plus an ID token.- Trust is a federated identity credential whose issuer, subject and audience must match the token.
- Subjects are matched exactly, so a credential for
maindoes not work for other branches. - The identity needs the "Key Vault Secrets User" role (with RBAC) and nothing more.
Practice
- (Beginner) Create a Key Vault secret and an app registration with a federated credential for your project's
mainbranch. Fetch the secret in a job onmain.- (Intermediate) Run the same job on a feature branch and capture the error. Then decide whether to add a second federated credential or a separate identity for non-production branches, and implement it.
- Interview question: "A colleague created an Azure client secret for the pipeline and stored it as a masked GitLab variable. How would you migrate to federated credentials, and what do you gain?" Hint: compare credential lifetime, rotation effort and what a leaked value lets an attacker do.
Google Cloud Secret Manager Integration
Why it matters. On Google Cloud, service account JSON keys are the traditional way for CI to authenticate, and they are a well-known source of leaks: they never expire unless rotated and are trivially reusable. Workload Identity Federation (WIF) removes them. GitLab can use WIF to read from Secret Manager directly.
What it is. secrets:gcp_secret_manager (Premium and Ultimate) exchanges the job's ID token at Google's Security Token Service through a workload identity pool and provider. It then reads the secret with the permissions granted to that federated identity.
variables:
GCP_PROJECT_NUMBER: "123456789012"
GCP_WORKLOAD_IDENTITY_FEDERATION_POOL_ID: gitlab-pool
GCP_WORKLOAD_IDENTITY_FEDERATION_PROVIDER_ID: gitlab-provider
deploy:
id_tokens:
GCP_ID_TOKEN:
# Audience must identify the WIF provider
aud: https://iam.googleapis.com/projects/${GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/${GCP_WORKLOAD_IDENTITY_FEDERATION_POOL_ID}/providers/${GCP_WORKLOAD_IDENTITY_FEDERATION_PROVIDER_ID}
secrets:
DATABASE_PASSWORD:
gcp_secret_manager:
name: shop-db-password # secret name in Secret Manager
version: 1 # version number, or omit for the latest
token: $GCP_ID_TOKEN
script:
- ./deploy.sh
environment: production
Google Cloud side:
# 1. Pool and OIDC provider trusting GitLab, mapping token claims to attributes
gcloud iam workload-identity-pools create gitlab-pool --location=global
gcloud iam workload-identity-pools providers create-oidc gitlab-provider \
--location=global --workload-identity-pool=gitlab-pool \
--issuer-uri="https://gitlab.example.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.gitlab_project_id=assertion.project_id,attribute.ref_protected=assertion.ref_protected" \
--attribute-condition="assertion.ref_protected == 'true'" # only protected refs may federate
# 2. Allow identities from GitLab project 42 to read one secret
gcloud secrets add-iam-policy-binding shop-db-password \
--role="roles/secretmanager.secretAccessor" \
--member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/gitlab-pool/attribute.gitlab_project_id/42"
The attribute mapping decides which claims you can use in principalSet members and in the attribute condition. Mapping project_id and ref_protected lets you grant access per project and refuse unprotected refs at the pool level.
Key takeaways
secrets:gcp_secret_manager(Premium) uses Workload Identity Federation instead of service account keys.- Three variables identify the pool and provider, and the ID token's
audmust point at that provider. - Map GitLab claims to attributes, then grant
roles/secretmanager.secretAccessorper secret to aprincipalSetfor the project. - Use an attribute condition such as
ref_protected == 'true'to refuse untrusted refs before any IAM check.
Practice
- (Beginner) Create a pool, provider and secret, and read the secret from a job on a protected branch.
- (Intermediate) Add
attribute.environment=assertion.environmentto the mapping and restrict a production secret to jobs withenvironment: production. Verify that a staging job on the same branch is denied.- Interview question: "Why is an attribute condition on the provider more robust than relying only on IAM bindings to block feature branches?" Hint: think about defense in depth and about bindings that other teams may add later.
GitLab Secrets Manager
Why it matters. External secret stores are powerful, but they need infrastructure, trust configuration and someone to run them. Small and medium teams often fall back to CI/CD variables, which were never designed as a secrets manager: they have no fine-grained permissions on who can read which secret, no rotation reminders and limited auditing. GitLab Secrets Manager is a native store with secret-manager semantics, built into GitLab.
What it is. GitLab Secrets Manager (Premium and Ultimate, generally available since GitLab 19.5) stores secrets in an OpenBao backend managed by GitLab. It is enabled per top-level group on GitLab.com (Secure > Secrets manager, billed through GitLab Credits) or by an administrator on self-managed instances, where OpenBao must be installed first. Jobs need GitLab Runner 19.0 or later. GitLab handles authentication itself, so no id_tokens or trust configuration is required in the YAML.
Secrets are created in Secure > Secrets manager > New secret:
| Field | Notes |
|---|---|
| Name | Letters, digits and underscores. Unique, cannot be changed later |
| Value | Up to 10 KB |
| Environments | All (*), a specific environment, or a wildcard |
| Branches (project secrets) | A specific branch or a wildcard such as release/* |
| Protected branches (group secrets) | Restrict to pipelines on protected branches |
| Rotation reminder | Optional, at least 7 days. Owners get an email when rotation is due |
By default a project can store 100 secrets and a group 500.
deploy:
environment: production # must match the secret's environment scope
secrets:
KUBE_CA_PEM:
gitlab_secrets_manager:
name: kube_cert # project secret
REGISTRY_TOKEN:
gitlab_secrets_manager:
name: registry_token
source: group/acme/platform # group secret: group/<full group path>
file: false # plain value instead of a file path
script:
- kubectl config set-cluster prod --server="$KUBE_SERVER" --certificate-authority="$KUBE_CA_PEM"
- ./deploy.sh
Permissions are managed separately from repository roles. In GitLab 19.4 and later, project Owners manage secrets permissions, Maintainers can read metadata, create and update by default, and Developers can read metadata and create but not update. For group secrets, permissions must be granted explicitly to users or roles. Reading and using secrets is audited.
| CI/CD variables | GitLab Secrets Manager | External store (Vault, cloud) | |
|---|---|---|---|
| Setup effort | None | Enable per group or instance | Run or buy a store, configure OIDC trust |
| Scoping | Protected refs, environment | Environment, branch, protected branches | Anything the store's policies express |
| Read access in the UI | Maintainers can reveal (unless hidden) | Values never shown, separate permissions | Store's own access control |
| Rotation | Manual | Reminders | Automated, dynamic credentials possible |
| Cost | Included | Premium + GitLab Credits | Infrastructure or cloud fees |
| Best for | Non-secret configuration, simple secrets | Teams wanting a native store with little setup | Organizations with existing central secret management |
Key takeaways
- GitLab Secrets Manager is a native, OpenBao-backed secret store (Premium, GA in 19.5) that needs no external infrastructure on GitLab.com.
- Secrets are scoped by environment and by branch (project) or protected branches (group), with optional rotation reminders.
- Jobs use
secrets:<VAR>:gitlab_secrets_manager:name, withsource: group/<path>for group secrets. Runner 19.0+ is required. - Its permissions are separate from repository roles, which fixes the "every Maintainer can read every secret" problem of CI/CD variables.
Practice
- (Beginner) Enable the secrets manager, create a project secret scoped to
production, and use it in a job withenvironment: production. Run a job without an environment and observe the result.- (Intermediate) Move three masked CI/CD variables of a project into GitLab Secrets Manager, scoping them by environment and branch. Plan the cut-over so that no pipeline runs without credentials, and delete the old variables at the end.
- Interview question: "Your company already runs Vault. A team asks to use GitLab Secrets Manager instead for its new service. What questions would you ask before agreeing?" Hint: think about a single source of truth, audit requirements, dynamic credentials, cost and who operates each system.
<a id="6-gitlab-runners"></a>