Variables: pipeline context and secrets
Built-in CI_ variables, your own values in .gitlab-ci.yml, and masked secrets in project settings. Plus a colon that breaks YAML.
Built-in CI_ variables, your own values in .gitlab-ci.yml, and Masked secrets in the project
settings. Plus a colon that breaks YAML.
Why this matters
A pipeline with no variables only knows what's typed into the file by hand. A hardcoded commit hash
goes stale with the very next commit, a hardcoded token is visible to anyone who opens the
repository. Variables solve both problems: GitLab passes the run's context to the job itself, and
secrets live in the project settings and show up in the log as [MASKED].
How a job knows where it's running
The runner gets several dozen variables from GitLab: who started the pipeline, from which branch, which commit, the job number. You don't declare them, they're already there.
The lesson material lives in a training repository. If you did the earlier lessons on your own
GitLab project, add a second remote: origin points at your project, and lesson updates come from
the training one.
git remote add upstream https://gitlab.com/izidevops-learn/ci-start.git
git pull --no-rebase upstream main
cp lesson-06-variables/pipeline.yml .gitlab-ci.yml
The --no-rebase flag is required. By this point your history has diverged from the training
repo's: you've already made your own commits with a pipeline. On diverged branches, git refuses to
guess what you want:
fatal: Need to specify how to reconcile divergent branches.
--no-rebase tells it "make a regular merge commit." You can also turn this on once for good:
git config pull.rebase false.
Built-in variables
show-context:
stage: info
script:
- echo "Project $CI_PROJECT_NAME, branch $CI_COMMIT_BRANCH"
- echo "Commit $CI_COMMIT_SHORT_SHA, author $GITLAB_USER_LOGIN"
- echo "Pipeline number $CI_PIPELINE_ID, job number $CI_JOB_ID"
You'll get something like this in the log:
Project my-ci-site, branch main
Commit 9f09955b, author vladislav.makhankov
Pipeline number 2782918422, job number 16052273647
These values turn a pipeline from a script into a tool. An image tag from
CI_COMMIT_SHORT_SHA, an environment name from CI_COMMIT_BRANCH, a job link in a notification
from CI_JOB_ID. CI_COMMIT_SHORT_SHA holds the first eight characters of the full hash,
CI_COMMIT_SHA.
Run env in any job to see the full list. There really are a lot of them: GitLab's reference
lists over a hundred, and the set depends on what triggered the pipeline. For example,
CI_COMMIT_BRANCH exists in a branch pipeline, but is empty in a pipeline for a tag or a merge
request.
Your own variables
Declared with a variables block at the root of the file or inside a job:
variables:
SITE_TITLE: "Built by the pipeline"
BUILD_DIR: "public"
pages:
stage: build
script:
- mkdir -p "$BUILD_DIR"
- cp style.css "$BUILD_DIR/"
- sed -e "s|<h1>.*</h1>|<h1>$SITE_TITLE</h1>|"
-e "s|<dd id=\"commit\">.*</dd>|<dd id=\"commit\">$CI_COMMIT_SHORT_SHA</dd>|"
-e "s|<dd id=\"branch\">.*</dd>|<dd id=\"branch\">$CI_COMMIT_BRANCH</dd>|"
index.html > "$BUILD_DIR/index.html"
artifacts:
paths:
- $BUILD_DIR
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
This is where variables first give a visible result. The page has empty "commit" and "branch"
fields, and sed fills them in with values from the job's environment. Push, wait for the deploy,
and refresh your site: the card will show the short commit hash it was built from.
Variables expand in artifacts.paths too, not just in script. A variable inside a job overrides
one with the same name from the root.
The quotes around "$BUILD_DIR" are there for a reason. Without them, a path with a space would
split into two arguments, and mkdir would create something other than what you expected.
Secrets live in settings, not in the file
.gitlab-ci.yml lives in git and is visible to everyone with access to the repository. For a
public project, that's literally everyone. Tokens and passwords don't belong there.
Open Settings → CI/CD → Variables → Add variable. Key DEPLOY_TOKEN, any value, and
most importantly, visibility. GitLab offers three options. Starting with version 18.3, new
variables are created as Masked by default.
Visible — a plain variable, its value is visible in the settings and in logs. For flags and paths.
Masked — the value is replaced with [MASKED] everywhere it would land in a job's log. You can
still view it in the settings.
Masked and hidden — the same, plus you can't view it in the UI once it's saved. Use this mode for production tokens: even you won't see the value again, only overwrite it.
You can only turn on hiding when creating a variable. You can't switch an existing one into this mode later, you'll have to delete it and create it again.
What masking catches, and what it doesn't
Try this on a live job:
show-secret:
stage: info
script:
- 'echo "Token length: ${#DEPLOY_TOKEN}"'
- 'echo "The token itself: $DEPLOY_TOKEN"'
In the log:
Token length: 22
The token itself: [MASKED]
The value is hidden, but the length leaked. Masking looks for an exact match with the secret
string in the output and replaces it. Anything you computed from the secret yourself (a length, a
hash, the first few characters through cut) sails through to the log untouched. Same with any
changed form of the value: if the shell escapes special characters in it, there's no longer an
exact match.
Which gives a practical rule: don't print the secret at all, not even partially. Feed it straight into the command, ideally through stdin:
- echo "$DEPLOY_TOKEN" | docker login -u ci --password-stdin registry.example.com
Masking has requirements for the value: at least 8 characters, a single line with no spaces, and
the value can't match the name of another variable. If GitLab won't let you turn on Masked, the
reason is usually on that list. One more limit: with Expand variable reference checked, the
value may only contain letters, digits, and the characters _ : @ - + . ~ = /. For secrets, it's
better to leave that checkbox off.
Multiline secrets: the base64 trick
Masking requires a value with no line breaks, but secrets are often multiline: a kubeconfig, a private SSH key, a service account JSON. You can put them into a variable as they are, but you won't be allowed to turn on Masked for a value like that.
The workaround is base64 encoding. On your own machine:
base64 < kubeconfig.yaml | tr -d '\n'
tr -d '\n' is required: on Linux, base64 wraps the line every 76 characters, and without it
you'd get back the same multiline value you were trying to get away from. On macOS, base64
doesn't wrap the line by default, but the extra tr won't hurt.
You put the resulting string into a variable named KUBECONFIG_B64 with Masked visibility, and in
the pipeline you unpack it back into a file:
deploy:
script:
- echo "$KUBECONFIG_B64" | base64 -d > /tmp/kubeconfig
- export KUBECONFIG=/tmp/kubeconfig
- kubectl get pods
The file lands in /tmp, not in the working folder: otherwise it risks traveling into the artifact
along with the rest of the build.
The variable settings also have a File type: GitLab puts the value into a temporary file for
you and writes the path to it into the variable. That's convenient for kubectl and the AWS CLI,
but masking holds a File variable to the same requirements: a multiline kubeconfig still can't be
masked. So for multiline secrets, the base64 trick stays the working option.
What base64 doesn't do
Base64 is encoding, not encryption. Anyone can run base64 -d to decode it, so the trick has
exactly one purpose: packing a multiline value into a single line.
And there's a trap you can see on a live job:
$ echo "Raw variable: $KUBECONFIG_B64"
Raw variable: [MASKED]
$ cat /tmp/kubeconfig
apiVersion: v1
clusters:
- cluster:
server: https://k8s.example.com
name: prod
...
The variable itself is masked. But the file's content, once decoded, poured into the log in full: masking compares the output against the base64 string, and the decoded text doesn't match it.
Which gives a rule: once you've decoded it into a file, don't print the file. Not cat, not
kubectl config view --raw (without the --raw flag, certificates and tokens are hidden; with it,
they're shown), not a "quick" debug print either.
The colon that breaks the pipeline
Notice the quotes around the entire line:
- 'echo "Token length: ${#DEPLOY_TOKEN}"'
Without the outer quotes, GitLab responds with this:
jobs:show-secret:script config should be a string
or a nested array of strings up to 10 levels deep
The cause is YAML, not GitLab. The sequence : (a colon followed by a space) inside an unquoted
string is read as a key-value separator, and the list item turns into a mapping. The double quotes
around the text after echo don't help: the string starts with the word echo, and as far as YAML
is concerned it's still unquoted. Wrap the whole of any command that contains : in single
quotes.
Common mistakes
- A value hardcoded into
.gitlab-ci.yml(a hash, a branch, a build number). It's correct for exactly one commit. Use the built-inCI_variable. CI_COMMIT_BRANCHis empty. The pipeline ran for a tag or a merge request, where this variable doesn't exist. For a tag there'sCI_COMMIT_TAG, andCI_COMMIT_REF_NAMEalways has the branch or tag name.- A variable inside single quotes inside a command (
sed 's|x|$SITE_TITLE|'). The shell won't expand it, and the literal text$SITE_TITLEends up in the file. - A secret in
.gitlab-ci.ymlor in a job'svariables. A variable like that is visible to everyone with access to the repository and isn't masked. Secrets only go into Settings → CI/CD → Variables. - Printing "just a little" of a secret for debugging: a length, the first few characters,
caton a decoded file. Masking won't catch any of it. - A decoded secret left in the job's working folder. It travels into the artifact, and anyone with access to the pipeline can download the artifact.
What to remember
Built-in CI_* variables give a job its context: project, branch, commit, pipeline and job
numbers.
Your own variables are declared in variables, and work in both script and artifacts.paths; a
value inside a job overrides the root one.
Secrets go into Settings → CI/CD → Variables with Masked visibility, and for production tokens, Masked and hidden.
Masking only hides an exact match. It won't catch a length, a hash, or a fragment of a secret, so don't print it at all.
Multiline secrets go into a variable through base64 and get unpacked into a file in /tmp. That's
packaging, not protection: masking no longer recognizes the decoded content.
A colon followed by a space inside a command needs single quotes around the whole line, or YAML will parse it as a mapping.
What you'll need for the tasks
In the labs, the pipeline is run by a practice runner on your laptop: like a GitLab Runner with the
Docker executor, it runs every job in a clean container and passes it CI_COMMIT_SHA,
CI_COMMIT_SHORT_SHA, CI_COMMIT_BRANCH, and CI_PROJECT_NAME. The project's variables live in
setup/ci-variables.txt, a local stand-in for Settings → CI/CD → Variables. The runner
replaces Masked variable values in the log with [MASKED], just like GitLab.
git -C work commit -am "...": the runner, like GitLab, only picks up what's committed.bash run-pipeline.sh: run the pipeline on the latest commit.- A
variablesblock at the root of.gitlab-ci.yml, and$NAMEinscriptandartifacts:paths. - Single quotes around a command with
:inside it. ls -R artifacts/: see what actually ended up in the artifact.
Next, we'll build the site with a real bundler in a Node container.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson cicd-practice-06-variables
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress