Sign in
← CI/CD: hands-on
14 min · easy · Lesson 6 of 10

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

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.

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.

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.

Get access for $49

Tell the agent: use MCP izi, give me lesson cicd-practice-06-variables