As soon as a pipeline becomes useful, it needs data. A build may need the current branch name. A deploy job may need a cloud token. A Docker publish step may need a registry URL. A staging deployment may need one database connection string, while production needs another. This is where GitLab CI/CD variables become essential.
This tutorial explains GitLab CI variables, secrets, and environments in depth. You will learn what variables are, why they matter, which predefined variables are most useful, how to define custom variables in different places, how precedence works when values collide, how masked and protected variables improve security, how environment-scoped variables solve staging-versus-production differences, how group-level variables help teams reuse configuration across projects, how to reference variables in Linux and Windows scripts, how to pass values between jobs with a dotenv artifact, and how to handle multi-line credentials with file-type variables.
A CI/CD variable is a named value available to pipeline jobs at runtime.
Instead of hard-coding values into your repository, you inject them when the pipeline runs. That makes pipelines safer, more flexible, and easier to reuse.
Variables can represent:
staging or productionIf you place secrets directly in .gitlab-ci.yml or source code, you create obvious risks:
Variables let you keep sensitive or changing values outside the committed application logic.
GitLab automatically provides many predefined variables during pipeline execution. These variables describe the project, commit, branch, pipeline, environment, and registry context.
You do not define them yourself. GitLab injects them when jobs run.
Here are some of the most useful predefined variables for beginners.
CI_COMMIT_BRANCHCI_COMMIT_BRANCH contains the branch name that triggered the pipeline.
Use it when you want branch-aware behavior, such as:
mainExample:
script:
- echo "Running on branch $CI_COMMIT_BRANCH"CI_COMMIT_SHACI_COMMIT_SHA contains the full commit SHA for the pipeline's revision.
This is incredibly useful for:
Example:
script:
- echo "Building commit $CI_COMMIT_SHA"CI_PROJECT_NAMECI_PROJECT_NAME gives you the project name. It is helpful for logs, dynamic file names, or reusable scripts that behave differently depending on the project context.
CI_PIPELINE_IDCI_PIPELINE_ID is a unique numeric identifier for the pipeline.
This is useful when you want:
CI_REGISTRYCI_REGISTRY points to the container registry associated with GitLab.
If you are building and pushing container images, this variable helps avoid hard-coding registry locations.
CI_ENVIRONMENT_NAMECI_ENVIRONMENT_NAME becomes especially useful in deployment jobs that define an environment such as staging or production.
It lets scripts know which environment they are targeting.
Example:
deploy:
stage: deploy
environment:
name: staging
script:
- echo "Deploying to $CI_ENVIRONMENT_NAME"These built-in values make pipelines context-aware without requiring manual configuration for every run. They are the foundation for reusable automation.
.gitlab-ci.ymlNot every value comes from GitLab automatically. You will often define your own custom variables.
One place to do this is directly in .gitlab-ci.yml.
You can define variables at the top level so many jobs can reuse them:
variables:
APP_ENV: "staging"
API_BASE_URL: "https://api.example.com"These values are easy to use across jobs.
You can also define variables only for a specific job:
run-tests:
stage: test
variables:
NODE_ENV: "test"
script:
- echo "Running with $NODE_ENV"This keeps configuration close to the job that needs it.
Variables in .gitlab-ci.yml are fine for:
They are not the right place for sensitive secrets such as production passwords or private keys.
For secret or environment-specific values, the GitLab UI is often a better place.
Inside a project, you can usually add variables under Settings -> CI/CD -> Variables.
This approach is useful because:
Examples of variables that often belong in project settings:
DATABASE_URLAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYDEPLOY_TOKENSLACK_WEBHOOK_URLIf your organization has multiple related projects, repeating the same variable in every project quickly becomes tedious.
That is where group-level variables help.
A group-level variable is defined once at the GitLab group level and can be inherited by projects within that group.
They are ideal for shared configuration such as:
Of course, you should still use principle-of-least-privilege thinking. Shared values should only be shared when that scope truly makes sense.
A very practical question is: What happens if the same variable name is defined in multiple places?
That is where variable precedence matters.
In plain language, the most specific or higher-priority definition wins over a broader default.
While exact precedence can involve several pipeline sources and policy layers, a beginner-friendly mental model is:
Suppose you define:
API_URL=https://api.company.comAPI_URL=https://api.special-service.comAPI_URL=https://api.test.localThe job may see the most specific value intended for that context rather than the broadest one.
To reduce confusion:
STAGING_DB_URL and PRODUCTION_DB_URL when appropriateGood naming is often easier to manage than clever precedence tricks.
A masked variable helps prevent sensitive values from being exposed in job logs.
If a job outputs a masked value exactly, GitLab attempts to replace it with [MASKED] or an equivalent hidden representation in the logs.
Logs are frequently read by many people. If a token accidentally appears in output, masking reduces the risk of an immediate secret leak.
For GitLab to mask a variable reliably, the value must satisfy certain requirements. A common beginner summary is:
If the value does not meet those rules, GitLab may reject masking or fail to mask the value safely.
Masking is a safety net, not a license to print secrets. You should still avoid commands that echo secret values directly.
Bad example:
script:
- echo "$DEPLOY_TOKEN"Even if masking is enabled, printing secrets is poor practice. Some transformations or formatting can bypass exact masking behavior.
A protected variable is only exposed to jobs that run on protected branches or protected tags.
This is especially important for production credentials.
Imagine an untrusted feature branch pipeline had access to your production cloud secrets. That would be dangerous. Protected variables reduce that risk by limiting secret availability to trusted refs such as:
mainrelease/*Protected variables are often used for:
For highly sensitive values, the strongest beginner default is often to make them both masked and protected.
That combination means:
This is a strong baseline for secrets such as:
It is not the whole security story, but it is a much better start than plain text secrets in YAML.
A pipeline often deploys to more than one environment. Staging and production should not share the same secrets.
That is where environment-scoped variables shine.
You can define the same variable name with different values depending on the environment scope.
For example:
DB_URL scoped to staging -> points to the staging databaseDB_URL scoped to production -> points to the production databaseThen your deploy job can reference the same variable name while GitLab injects the correct value for the target environment.
This lets you keep your pipeline logic clean. Instead of hard-coding different variable names throughout the scripts, you can write deployment logic once and let GitLab provide the appropriate environment-specific value.
A deployment job defines:
environment:
name: productionBecause the environment is production, GitLab can provide the DB_URL value scoped to production rather than staging.
Environment scopes are excellent for:
How you reference a variable depends on the shell environment.
In most GitLab Docker jobs, you use shell syntax like:
$VAR_NAMEor:
${VAR_NAME}Example:
script:
- echo "Project: $CI_PROJECT_NAME"
- echo "Branch: $CI_COMMIT_BRANCH"In Windows command environments, variable syntax often looks like:
%VAR_NAME%That matters if your runner uses a Windows shell rather than a Linux-based container.
A variable may exist correctly but still appear âbrokenâ if you use the wrong shell syntax.
Sometimes a job computes a value during runtime and a later job needs it. This is where artifacts:reports:dotenv becomes very useful.
Imagine a build job generates:
A later deploy job needs that value, but ordinary shell exports do not automatically carry across jobs because each job runs in its own isolated environment.
A job can write variables into a dotenv-style file, then publish that file as a report artifact.
Example:
build-version:
stage: build
script:
- echo "APP_VERSION=1.2.$CI_PIPELINE_ID" > build.env
artifacts:
reports:
dotenv: build.env
deploy:
stage: deploy
script:
- echo "Deploying version $APP_VERSION"When configured correctly, downstream jobs can receive APP_VERSION automatically.
This approach is cleaner than copy-pasting values into multiple jobs or trying to infer runtime data repeatedly.
Some secrets are not simple one-line strings. Examples include:
For these cases, GitLab supports file-type variables.
Instead of exposing the raw content directly as a normal string value, GitLab makes the variable available as a file path pointing to a temporary file containing the secret content.
This is useful because many tools expect a file rather than a shell variable.
They help avoid awkward quoting problems and make multi-line credential handling much more practical.
Example use cases:
kubectl using a kubeconfig fileLet us bring the concepts together.
Imagine a project that has:
A sensible setup might look like this:
.gitlab-ci.ymlstages:
- test
- deploy
variables:
APP_NAME: "shop-service"
test-api:
stage: test
image: node:20
script:
- echo "Testing $APP_NAME on branch $CI_COMMIT_BRANCH"
- npm ci
- npm test
deploy-staging:
stage: deploy
image: alpine:3.20
environment:
name: staging
script:
- echo "Deploying $APP_NAME to $CI_ENVIRONMENT_NAME"
- ./deploy.sh "$DB_URL" "$API_KEY"
deploy-production:
stage: deploy
image: alpine:3.20
environment:
name: production
script:
- echo "Deploying $APP_NAME to $CI_ENVIRONMENT_NAME"
- ./deploy.sh "$DB_URL" "$API_KEY"You might define:
API_KEY as maskedDEPLOY_TOKEN as masked and protectedDB_URL scoped separately for staging and productionCLOUD_SERVICE_ACCOUNT_JSONThis is exactly the kind of pattern that scales well.
Security is not just about features. It is about habits.
Even masked secrets should not be printed deliberately.
Masking helps reduce accidental exposure in logs.
Production secrets should normally be limited to protected refs.
A secret that never changes becomes a long-term liability. Build a rotation habit.
A token should only be able to do what the job actually needs.
Even private repositories are not a good excuse for committing secrets.
They are easier to manage safely than trying to inline complex content.
Secret protection is only as good as the branch and permission model around it.
A few mistakes appear frequently when people first learn GitLab CI secrets management.
A value in .gitlab-ci.yml is visible in the repository. Do not put production secrets there.
If staging and production share the same DB_URL, you might accidentally deploy production code against the wrong database.
Masking helps, but you should still avoid printing secret values.
Different jobs and environments should not always share the same broad credential.
If the same variable exists in multiple places, debugging can become confusing unless naming and ownership are clear.
Variables are the configuration language of real-world pipelines. They let you inject context, protect secrets, reuse logic across environments, and keep sensitive data out of version control.
In GitLab CI/CD, the most important things to remember are:
If you design variable management well early on, your pipelines become much safer and much easier to scale.
<MCQExercise exercise={{ id: "gitlab-vars-q1", title: "Question 1: Predefined Variables", description: "Which predefined GitLab CI variable is most useful when you want to know the branch that triggered the pipeline?", options: [ { id: "a", text: "CI_COMMIT_BRANCH" }, { id: "b", text: "CI_PIPELINE_ID" }, { id: "c", text: "CI_PROJECT_NAME" }, { id: "d", text: "CI_REGISTRY" }, ], correctId: "a", explanation: "CI_COMMIT_BRANCH contains the branch name associated with the pipeline, which makes it the right variable for branch-aware logic.", wrongExplanations: { b: "CI_PIPELINE_ID identifies the pipeline run, not the branch name.", c: "CI_PROJECT_NAME gives the repository or project name, not the triggering branch.", d: "CI_REGISTRY refers to the container registry location, not source control branch context.", }, }} />
<MCQExercise exercise={{ id: "gitlab-vars-q2", title: "Question 2: Secret Protection", description: "What is the main benefit of marking a variable as both masked and protected?", options: [ { id: "a", text: "It automatically copies the secret into the repository history" }, { id: "b", text: "It hides the value in logs where possible and limits it to protected branches or tags" }, { id: "c", text: "It makes the variable available to every fork without approval" }, { id: "d", text: "It converts the variable into a dotenv artifact automatically" }, ], correctId: "b", explanation: "Masked plus protected is a strong baseline for production secrets because the value is hidden in logs where possible and restricted to trusted refs.", wrongExplanations: { a: "Protected and masked settings help reduce exposure; they do not store the secret in Git history.", c: "Protected variables are specifically meant to reduce exposure, not widen it to every fork.", d: "Dotenv artifacts are a separate mechanism for passing generated values between jobs.", }, }} />
<MCQExercise exercise={{ id: "gitlab-vars-q3", title: "Question 3: Environment Scope", description: "Why are environment-scoped variables useful in GitLab CI/CD?", options: [ { id: "a", text: "They let you store only one global value for every environment" }, { id: "b", text: "They make YAML indentation optional in deploy jobs" }, { id: "c", text: "They allow the same variable name to provide different values for environments like staging and production" }, { id: "d", text: "They replace the need for runners and job scripts entirely" }, ], correctId: "c", explanation: "Environment-scoped variables let one pipeline reuse the same variable name while GitLab injects different values for different deployment targets.", wrongExplanations: { a: "The point is to support different values per environment, not to force one global value.", b: "Environment scopes do not change YAML syntax rules.", d: "Variables help configure jobs, but they do not replace runners or scripts.", }, }} />
Masking replaces the value with [MASKED] wherever it would appear in a job log, which prevents accidental disclosure through debug output. Protection restricts the variable to jobs running on protected branches and protected tags, so ordinary feature branches and merge requests never receive it. They solve different problems and production secrets should have both: masking alone still exposes the value to any branch, and protection alone still risks printing it into a log.
GitLab can only mask values that meet its requirements: a minimum length, a single line, and no characters outside the supported set. A value with spaces, newlines or unusual punctuation is rejected for masking, and GitLab tells you so when you save it rather than silently failing. Base64-encoding an awkward value is the usual workaround. Note that masking only covers exact matches: if a script transforms the secret before printing it, the transformed form appears in the log unmasked.
The most specific definition wins. Roughly, from lowest to highest precedence: instance variables, then group, then project, then variables defined in .gitlab-ci.yml at the top level, then job-level variables, and finally values passed when manually triggering a pipeline or by a trigger token. Predefined variables that GitLab supplies cannot be overridden. When a value is not what you expect, printing it in a throwaway job is the quickest way to confirm which layer supplied it.
Write it to a dotenv file and expose that file as a dotenv report artifact. Variables in it are then automatically available as environment variables in subsequent jobs, which is the supported way to hand a computed value, a generated image tag, a deployment URL, a version number, down the pipeline. Plain export does not work, because each job runs in its own container and shell with no shared state.
Not if the variables are marked protected, which is exactly what protection is for. An unprotected variable is available to any pipeline including those from forked repositories, which means an untrusted contributor could add a job that prints it. This is the single most important reason to mark production credentials protected as well as masked, and to review changes to .gitlab-ci.yml in merge requests from outside your organisation with particular care.
Build and push Docker images in GitLab CI: compare Docker-in-Docker with Kaniko, authenticate to the Container Registry, pick tags and follow a full example.
Run tests in GitLab CI: collect JUnit reports, view results in merge requests, extract coverage with a regex, run in parallel and follow a Jest pipeline.
Add security scanning to GitLab CI: SAST, DAST, secret detection, dependency and container scanning, and DevSecOps habits for secure pipelines.