Variables, Secrets and Environments in GitLab CI
Master GitLab CI/CD variables, predefined variables, custom variables, masked and protected secrets, group-level variables, environment-scoped variables, and .env file injection.
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.
What Are CI/CD Variables and Why Do They Matter?
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.
Common things variables store
Variables can represent:
- environment names such as
stagingorproduction - URLs such as API endpoints or database connections
- version identifiers such as commit SHA values
- credentials such as deploy tokens and API keys
- feature flags or build-time settings
- generated values passed from one job to another
Why hard-coding is a bad habit
If you place secrets directly in .gitlab-ci.yml or source code, you create obvious risks:
- secrets become visible in Git history
- rotating credentials becomes painful
- the same repository cannot easily support multiple environments
- teammates may accidentally leak tokens in forks or logs
Variables let you keep sensitive or changing values outside the committed application logic.
Predefined Variables in GitLab CI
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_BRANCH
CI_COMMIT_BRANCH contains the branch name that triggered the pipeline.
Use it when you want branch-aware behavior, such as:
- deploying only from
main - naming preview environments from feature branches
- adding branch names to logs or build metadata
Example:
script:
- echo "Running on branch $CI_COMMIT_BRANCH"
CI_COMMIT_SHA
CI_COMMIT_SHA contains the full commit SHA for the pipeline's revision.
This is incredibly useful for:
- tagging Docker images immutably
- producing traceable release artifacts
- linking deployments back to exact source commits
Example:
script:
- echo "Building commit $CI_COMMIT_SHA"
CI_PROJECT_NAME
CI_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_ID
CI_PIPELINE_ID is a unique numeric identifier for the pipeline.
This is useful when you want:
- unique artifact labels
- build metadata tied to a run
- simple traceability across logs and systems
CI_REGISTRY
CI_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_NAME
CI_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"
Why predefined variables are powerful
These built-in values make pipelines context-aware without requiring manual configuration for every run. They are the foundation for reusable automation.
Defining Custom Variables in .gitlab-ci.yml
Not 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.
Global variables
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.
Job-level variables
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.
When YAML-defined variables are appropriate
Variables in .gitlab-ci.yml are fine for:
- non-secret settings
- defaults that apply to all environments
- feature toggles for pipeline behavior
- readable examples and templates
They are not the right place for sensitive secrets such as production passwords or private keys.
Defining Variables in Project Settings
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:
- secrets stay out of the repository
- values can be updated without changing code
- masking and protection can be enabled
- environment scopes can be applied
Examples of variables that often belong in project settings:
DATABASE_URLAWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYDEPLOY_TOKENSLACK_WEBHOOK_URL
Group-Level Variables
If 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.
Why group-level variables are useful
They are ideal for shared configuration such as:
- a common package registry token
- organization-wide API endpoints
- standard cloud account IDs
- deployment credentials used by several services
Benefits
- less duplication
- more consistent configuration
- easier rotation of shared secrets
- simpler onboarding for new projects
Of course, you should still use principle-of-least-privilege thinking. Shared values should only be shared when that scope truly makes sense.
Variable Precedence: Which Value Wins?
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:
- job-level variables can override broader YAML defaults
- more specific pipeline-supplied values can override general ones
- project or group settings may override or be overridden depending on the source and configuration context
Why this matters in practice
Suppose you define:
- a group variable
API_URL=https://api.company.com - a project variable
API_URL=https://api.special-service.com - a job variable
API_URL=https://api.test.local
The job may see the most specific value intended for that context rather than the broadest one.
Best practice for sanity
To reduce confusion:
- avoid duplicate variable names unless you intentionally want overrides
- use clear naming like
STAGING_DB_URLandPRODUCTION_DB_URLwhen appropriate - document your variable strategy for the team
Good naming is often easier to manage than clever precedence tricks.
Masked Variables
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.
Why masking matters
Logs are frequently read by many people. If a token accidentally appears in output, masking reduces the risk of an immediate secret leak.
Important masked variable requirements
For GitLab to mask a variable reliably, the value must satisfy certain requirements. A common beginner summary is:
- it should not contain newlines
- it should be at least 8 characters long
- it must fit GitLab's masking rules and supported character patterns
If the value does not meet those rules, GitLab may reject masking or fail to mask the value safely.
Very important limitation
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.
Protected Variables
A protected variable is only exposed to jobs that run on protected branches or protected tags.
This is especially important for production credentials.
Why protected variables matter
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 tags used for formal releases
Typical use cases
Protected variables are often used for:
- production deploy tokens
- production cloud keys
- signing credentials
- release publishing secrets
Masked + Protected: The Best Combination for Production Secrets
For highly sensitive values, the strongest beginner default is often to make them both masked and protected.
That combination means:
- the value is hidden in logs where possible
- the value is only available on trusted branches or tags
This is a strong baseline for secrets such as:
- production database credentials
- container registry deploy tokens
- cloud provider keys used only for production release jobs
It is not the whole security story, but it is a much better start than plain text secrets in YAML.
Environment-Scoped Variables
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.
The core idea
You can define the same variable name with different values depending on the environment scope.
For example:
DB_URLscoped tostaging-> points to the staging databaseDB_URLscoped toproduction-> points to the production database
Then your deploy job can reference the same variable name while GitLab injects the correct value for the target environment.
Why this is elegant
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.
Example mental model
A deployment job defines:
environment:
name: production
Because the environment is production, GitLab can provide the DB_URL value scoped to production rather than staging.
Real-world value
Environment scopes are excellent for:
- database URLs
- API endpoints
- feature flag service keys
- cluster credentials
- per-environment service tokens
Referencing Variables in Scripts
How you reference a variable depends on the shell environment.
Linux and Unix-style shells
In most GitLab Docker jobs, you use shell syntax like:
$VAR_NAME
or:
${VAR_NAME}
Example:
script:
- echo "Project: $CI_PROJECT_NAME"
- echo "Branch: $CI_COMMIT_BRANCH"
Windows shells
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.
Why syntax awareness matters
A variable may exist correctly but still appear “broken” if you use the wrong shell syntax.
The Dotenv Artifact: Passing Variables Between Jobs
Sometimes a job computes a value during runtime and a later job needs it. This is where artifacts:reports:dotenv becomes very useful.
The problem it solves
Imagine a build job generates:
- a dynamic version number
- a release URL
- an image tag
- a short-lived derived variable
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.
The GitLab solution
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.
Why this is better than hacks
This approach is cleaner than copy-pasting values into multiple jobs or trying to infer runtime data repeatedly.
File-Type Variables
Some secrets are not simple one-line strings. Examples include:
- SSH private keys
- kubeconfig files
- JSON service account credentials
- certificate bundles
For these cases, GitLab supports file-type variables.
How file-type variables work
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.
Why they matter
They help avoid awkward quoting problems and make multi-line credential handling much more practical.
Example use cases:
kubectlusing a kubeconfig file- SSH commands using a private key file
- cloud SDKs reading JSON credentials from disk
A Complete Example: Managing Database URLs, API Keys, and Deploy Tokens Properly
Let us bring the concepts together.
Imagine a project that has:
- a test job that calls a public API
- a staging deployment
- a production deployment
- a Docker image push step
A sensible setup might look like this:
In .gitlab-ci.yml
stages:
- 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"
In project or group settings
You might define:
API_KEYas maskedDEPLOY_TOKENas masked and protectedDB_URLscoped separately forstagingandproduction- a file-type variable for
CLOUD_SERVICE_ACCOUNT_JSON
Why this setup is strong
- the repository contains only non-secret defaults
- staging and production use the same variable names but different scoped values
- production secrets are protected from untrusted branches
- sensitive values are not committed to Git
- the deploy scripts stay reusable
This is exactly the kind of pattern that scales well.
Security Best Practices for GitLab CI Secrets
Security is not just about features. It is about habits.
Never echo secrets
Even masked secrets should not be printed deliberately.
Use masked variables for sensitive strings
Masking helps reduce accidental exposure in logs.
Use protected variables for production credentials
Production secrets should normally be limited to protected refs.
Rotate secrets regularly
A secret that never changes becomes a long-term liability. Build a rotation habit.
Prefer least privilege
A token should only be able to do what the job actually needs.
Avoid storing secrets in the repository
Even private repositories are not a good excuse for committing secrets.
Use file-type variables for structured multi-line credentials
They are easier to manage safely than trying to inline complex content.
Review who can trigger protected pipelines
Secret protection is only as good as the branch and permission model around it.
Common Beginner Mistakes with Variables
A few mistakes appear frequently when people first learn GitLab CI secrets management.
Mistake 1: Using YAML variables for secrets
A value in .gitlab-ci.yml is visible in the repository. Do not put production secrets there.
Mistake 2: Forgetting environment scopes
If staging and production share the same DB_URL, you might accidentally deploy production code against the wrong database.
Mistake 3: Assuming masking makes logging safe
Masking helps, but you should still avoid printing secret values.
Mistake 4: Reusing one powerful token everywhere
Different jobs and environments should not always share the same broad credential.
Mistake 5: Ignoring precedence conflicts
If the same variable exists in multiple places, debugging can become confusing unless naming and ownership are clear.
Final Takeaway
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:
- predefined variables provide rich pipeline context
- custom variables can be defined in YAML, project settings, or group settings
- masked variables help hide secrets in logs
- protected variables restrict access to trusted branches and tags
- environment-scoped variables let one pipeline safely target multiple environments
- dotenv artifacts pass generated values from one job to another
- file-type variables are ideal for multi-line credentials
If you design variable management well early on, your pipelines become much safer and much easier to scale.
Knowledge Check
Question 1: Predefined Variables
Which predefined GitLab CI variable is most useful when you want to know the branch that triggered the pipeline?
Question 2: Secret Protection
What is the main benefit of marking a variable as both masked and protected?
Question 3: Environment Scope
Why are environment-scoped variables useful in GitLab CI/CD?