Managing the values in a pipeline: GitLab's built-in predefined variables and custom CI/CD variables, understanding the protected, masked, and expand security flags, then retrieving secrets securely from HashiCorp Vault directly in .gitlab-ci.yml with id_tokens.

In episode 4 you learned to control when jobs and pipelines run with rules and workflow. Now for the most important question in production pipelines: how does the pipeline "know" things about its context — which commit, what branch, what build number — and how do you store sensitive data like API keys or database passwords without leaking them?
The answer lies in variables and secret management. Understanding both is the point where your pipeline changes from "a script GitLab runs" into "a smart and secure program".
GitLab injects dozens of built-in variables into every job. Their values don't need to be set — GitLab fills them in automatically. The most commonly used:
| Variable | Content |
|---|---|
$CI_COMMIT_SHA | Hash of the commit being processed |
$CI_COMMIT_REF_NAME | Name of the branch or tag being processed |
$CI_COMMIT_BRANCH | Branch name (empty for tags) |
$CI_PROJECT_DIR | Location of the repository checked out on the runner |
$CI_PROJECT_PATH | Project path, e.g. mygroup/my-app |
$CI_PIPELINE_ID | Unique pipeline ID |
$CI_PIPELINE_SOURCE | Pipeline source: push, merge_request_event, schedule, etc. |
$CI_JOB_TOKEN | Job-specific token for authenticating to the GitLab API |
A real-world example — naming Docker images uniquely per commit:
build:
stage: build
script:
- echo "Image tag: $CI_COMMIT_SHA"
- echo "Branch : $CI_COMMIT_REF_NAME"Imagine how dangerous it would be if image tags weren't unique: two different commits could overwrite the same image and deployments become misleading. With $CI_COMMIT_SHA, every build has an identity that can't be confused.
Besides predefined variables, you can create your own. There are three places with different priorities:
Settings → CI/CD → Variables. Applies to one project, and values can be changed from the UI without a commit. Values here override values in the file.Group → Settings → CI/CD → Variables. Applies to all projects in one group; can be overridden at the project level..gitlab-ci.yml file — declared with the variables keyword, either globally or per job.variables:
APP_ENV: production
MAX_RETRY: 3
deploy:
stage: deploy
variables:
DEPLOY_USER: deployer
script:
- echo "Environment: $APP_ENV"
- echo "User: $DEPLOY_USER"The global variables applies to all jobs, while variables inside a job overrides the global value for that job only. Remember the priority order: variables from the UI project settings win, then group settings, then those in the file.
When creating a variable from the project settings UI, there are three toggles you must understand:
main). This prevents production values from leaking into feature-branch pipelines that are still being developed carelessly.REGISTRY_IMAGE=$CI_REGISTRY_IMAGE/my-app only works if Expand is enabled.| Flag | Purpose |
|---|---|
| Protected | Only for protected branches and tags |
| Masked | Hidden (asterisks) in output logs |
| Expand | Allows variable expansion within values |
Warning
Masked is not encryption — the value remains visible to users who have access to the project. For secrets that need centralized rotation and strict auditing, don't store them in GitLab variables. Use an external secret manager like HashiCorp Vault.
The problem with CI/CD variables: secrets are stored in GitLab, spread across many projects, hard to rotate, and with minimal auditing. The industry solution is HashiCorp Vault — a centralized secret manager where secrets are stored and retrieved according to policy. GitLab can fetch secrets directly from Vault while a job runs, without ever storing the secret value in GitLab.
The first step on the Vault side: enable JWT auth and create a role that binds a specific GitLab project:
vault auth enable jwt
vault write auth/jwt/role/gitlab-ci \
bound_issuer="https://gitlab.example.com" \
bound_audiences="https://gitlab.example.com" \
user_claim="sub" \
bound_subject="project_path:myorg/myapp:*" \
token_policies="ci-app" \
token_ttl="10m"A quick explanation: bound_issuer and bound_audiences match the JWT claims issued by GitLab, while bound_subject binds the role to the myorg/myapp project — the * at the end allows all branches of that project.
On the GitLab side, tell it where Vault is and which role to use. Go to Project → Settings → CI/CD → Vault server, and fill in:
| Field | Example Value |
|---|---|
| Vault server URL | https://vault.example.com:8200 |
| Vault auth role | gitlab-ci |
After that, write .gitlab-ci.yml with id_tokens and the secrets keyword:
deploy:
stage: deploy
image: node:20-alpine
id_tokens:
VAULT_JWT_TOKEN:
aud: https://gitlab.example.com
secrets:
DB_PASSWORD:
vault: kv/data/ci/app DB_PASSWORD@kv
API_KEY:
vault: kv/data/ci/app API_KEY@kv
file: false
script:
- npm run deployThree important things are happening here:
id_tokens creates an OIDC JWT with a specific aud claim. The runner uses this token to log in to Vault — no long-lived credentials are stored anywhere.secrets: keyword tells the runner: fetch this secret from Vault and turn it into an environment variable.vault: format is vault: <path> <field>@<mount>. For example kv/data/ci/app DB_PASSWORD@kv means reading the DB_PASSWORD field from the kv/data/ci/app path on the kv mount.When the job runs, DB_PASSWORD and API_KEY are available as environment variables — the repository stores no secrets at all. Rotating a secret is simply done in Vault, and all pipelines automatically use the new value.
Tip
For long secrets or ones containing strange characters, use file: true on the secrets: keyword. The secret value is written to a temporary file instead of an environment variable, reducing the risk of its value being printed to the log.
.gitlab-ci.yml. This file lives in the repository and leaks to anyone with repo access. Use UI variables or Vault for sensitive things.aud claim doesn't match. If the aud in id_tokens doesn't match the role's bound_audiences in Vault, authentication fails with an access denied error. Make sure both are exactly the same.In this episode 5, you've mastered the value system in pipelines:
$CI_COMMIT_SHA, $CI_COMMIT_REF_NAME, $CI_PROJECT_DIR, and $CI_PIPELINE_ID..gitlab-ci.yml with a clear priority order.id_tokens and the secrets keyword for secure, centralized, easily rotated secrets.Your pipeline can now talk, store configuration, and keep secrets safe. In episode 6 we'll move into container integration — Docker in GitLab CI/CD: running jobs with custom images, comparing Docker-in-Docker vs Kaniko, and publishing images to the GitLab Container Registry. See you in episode 6!