GitLab 14 🦊 Managing CI/CD Environment Variables and Secure Secrets
A pipeline without variables is a pipeline that hardcodes its configuration. The repository URL, the container registry, the deployment token, the database password—each of these changes between environments, and none of them should be committed to the .gitlab-ci.yml file. GitLab provides predefined variables that describe the pipeline context, and project/group variables that store configuration and secrets. The LFCS exam does not test GitLab, but the GitLab Certified Associate exam does. The critical distinction is between a variable (a key-value pair) and a secret (a variable that must never appear in logs or be accessible from untrusted branches). The masked and protected settings are the mechanisms that enforce this distinction.
Key point: Predefined variables (CI_COMMIT_SHA, CI_PIPELINE_ID, CI_PROJECT_DIR) are injected by GitLab and cannot be modified. Project variables are defined in the settings and injected into the job environment. A variable marked masked is hidden in job logs. A variable marked protected is only available in pipelines running on protected branches or tags. A secret should be both masked and protected.
Why variable management requires discipline
The hardcoding problem. A deployment job that contains password: "s3cr3t" in the .gitlab-ci.yml exposes the password to everyone with read access to the repository, to every fork, and to the job log. The correct approach is to store the password in a project variable, mark it masked and protected, and reference it as $DEPLOY_PASSWORD. The YAML file contains only the variable name, not the value .
The masking problem. A masked variable is replaced with [MASKED] in the job log. But masking is not encryption. It is a pattern-matching mechanism. A script that encodes the value (base64, hex) or splits it into fragments will bypass the mask and print the original value. The value must be at least 8 characters and cannot contain newlines. Masking is the last line of defense, not the only one .
The protected variable problem. A protected variable is only available to pipelines running on protected branches or tags. A merge request from a feature branch cannot access the production deployment token. This is the mechanism that prevents a malicious contributor from opening a merge request that prints the token to the job log. The variable is simply not present in the environment for that pipeline .
The environment scope problem. A variable can be scoped to a specific environment (production, staging). A job that declares environment: production receives the production-scoped variables. A job that declares environment: staging receives the staging-scoped variables. This allows the same variable name (DATABASE_URL) to have different values in different environments, without duplicating the YAML.
The trade-off. Variables are convenient, but they are less secure than a dedicated secrets manager (HashiCorp Vault, AWS Secrets Manager). GitLab’s own documentation acknowledges this: variable values are stored in the project settings, accessible to users with Maintainer permissions, and can be exposed by misconfiguration. For high-value secrets, a secrets manager is the correct solution. For everything else, masked and protected variables are sufficient .
a. Predefined variables and their use
Predefined variables are injected by GitLab. They describe the pipeline, the project, the commit, and the runner. They are read-only from the job’s perspective.
job:
script:
- echo "Commit: $CI_COMMIT_SHA"
- echo "Branch: $CI_COMMIT_BRANCH"
- echo "Pipeline: $CI_PIPELINE_ID"
- echo "Project: $CI_PROJECT_PATH"
- echo "Runner: $CI_RUNNER_DESCRIPTION"
The most commonly used predefined variables are CI_COMMIT_SHA (the commit hash), CI_COMMIT_BRANCH (the branch name, not set for tag pipelines), CI_COMMIT_TAG (the tag name, only set for tag pipelines), CI_PIPELINE_ID (the unique pipeline ID), CI_PIPELINE_SOURCE (how the pipeline was triggered), CI_PROJECT_DIR (the directory where the repository is cloned), and CI_REGISTRY_IMAGE (the container registry path for the project) .
The CI_JOB_TOKEN is a special predefined variable. It is a short-lived token that authenticates to the GitLab container registry and the package registry. It is valid only for the duration of the job. It should not be confused with a personal access token, which is long-lived and has broader permissions .
b. Defining project and group variables
Project variables are defined in Settings → CI/CD → Variables. Group variables are defined in the group’s settings and inherited by all projects in the group. The precedence order is: instance variables < group variables < project variables < pipeline variables < job variables .
Settings → CI/CD → Variables → Add variable
Key: DEPLOY_TOKEN
Value: s3cr3t-t0ken-123
Masked: Yes
Protected: Yes
Environment scope: production
The Key is the variable name. The Value is the secret. The Masked checkbox hides the value in logs. The Protected checkbox restricts the variable to protected branches and tags. The Environment scope limits the variable to jobs that declare the matching environment.
The variable is referenced in the YAML with the $ prefix.
deploy:
stage: deploy
environment: production
script:
- ./deploy.sh --token $DEPLOY_TOKEN
The deploy job declares environment: production, so it receives the DEPLOY_TOKEN scoped to production. A job without the environment declaration does not receive the variable.
c. Masking and protected variables in practice
A masked variable is hidden in the job log. The output $ echo $DEPLOY_TOKEN becomes $ echo [MASKED]. The actual value is not printed .
The masking rules are strict. The value must be at least 8 characters. It cannot contain newlines. It cannot be a base64-encoded string of a shorter value, because the masker would mask the encoded string but not the decoded value. The masker is a simple pattern matcher, not an encryption mechanism .
A protected variable is available only in pipelines running on protected branches or tags. The main branch is protected by default. A merge request from a feature branch to main runs in a merge request pipeline, which is not a protected branch pipeline. The protected variable is not available. The job that attempts to use it receives an empty string .
The combination of masked and protected is the standard for a secret. The mask hides the value in logs. The protection prevents access from untrusted branches. Neither alone is sufficient. A masked but unprotected variable can be exfiltrated by a feature branch merge request. A protected but unmasked variable can be printed to the log by a job on the main branch.
Complete Example Session
# ============================================
# PART 1: CREATE A MASKED VARIABLE
# ============================================
# Settings → CI/CD → Variables → Add variable
# Key: API_KEY
# Value: abc123def456
# Masked: Yes
# Protected: No
# ============================================
# PART 2: USE THE VARIABLE
# ============================================
test:
script:
- echo "API key is $API_KEY"
# Output: API key is [MASKED]
# ============================================
# PART 3: CREATE A PROTECTED VARIABLE
# ============================================
# Settings → CI/CD → Variables → Add variable
# Key: PROD_TOKEN
# Value: prod-s3cr3t
# Masked: Yes
# Protected: Yes
# ============================================
# PART 4: USE ON PROTECTED BRANCH
# ============================================
deploy:
script:
- ./deploy.sh --token $PROD_TOKEN
rules:
- if: $CI_COMMIT_BRANCH == "main"
# The job runs on main and receives the token.
# ============================================
# PART 5: OBSERVE THE PROTECTION
# ============================================
# A merge request from a feature branch runs a pipeline.
# The PROD_TOKEN is not available.
test-mr:
script:
- echo "Token: $PROD_TOKEN"
# Output: Token:
# The variable is empty.
# ============================================
# PART 6: ENVIRONMENT SCOPE
# ============================================
# Add a variable with environment scope "production"
# Key: DB_URL
# Value: postgres://prod-db:5432/app
# Protected: Yes
# ============================================
# PART 7: JOB WITH ENVIRONMENT
# ============================================
deploy-prod:
environment: production
script:
- echo "DB: $DB_URL"
# Receives the production-scoped DB_URL.
# ============================================
# PART 8: PREDEFINED VARIABLE
# ============================================
job:
script:
- echo "Commit: $CI_COMMIT_SHA"
- echo "Pipeline: $CI_PIPELINE_ID"
# ============================================
# PART 9: VERIFY MASKING IN LOG
# ============================================
# Job log shows:
# $ echo "API key is $API_KEY"
# API key is [MASKED]
The nine parts trace the variable lifecycle: masked variable, usage, protected variable, protected branch usage, protection observation, environment scope, job with environment, predefined variable, and log verification.
Quick Reference
Predefined Variables
| Variable | Value |
|---|---|
CI_COMMIT_SHA | Commit hash |
CI_COMMIT_BRANCH | Branch name (not for tags) |
CI_COMMIT_TAG | Tag name (only for tags) |
CI_PIPELINE_ID | Pipeline ID |
CI_PIPELINE_SOURCE | Trigger source |
CI_PROJECT_DIR | Clone directory |
CI_REGISTRY_IMAGE | Registry path |
CI_JOB_TOKEN | Short-lived job token |
Variable Settings
| Setting | Effect |
|---|---|
| Masked | Hides value in logs |
| Protected | Only on protected branches/tags |
| Environment scope | Limits to specific environments |
| Expanded | Allows variable references in value |
Variable Precedence
| Priority | Source |
|---|---|
| 1 (highest) | Job-level YAML |
| 2 | Pipeline variables |
| 3 | Project variables |
| 4 | Group variables |
| 5 (lowest) | Instance variables |
Masking Rules
| Rule | Requirement |
|---|---|
| Minimum length | 8 characters |
| Newlines | Not allowed |
| Encoding | Masking bypassed by base64/hex |
Best Practices
✅ Do This:
# Mark secrets as both masked and protected
<!-- The standard for production credentials --> # ✅
deploy:
environment: production
<!-- Scope variables to the environment --> # ✅
# Use CI_JOB_TOKEN for registry authentication
<!-- Short-lived and scoped to the job --> # ✅
❌ Don’t Do This:
# Hardcode secrets in .gitlab-ci.yml
<!-- Visible to everyone with read access --> # ❌
# Rely on masking as the only protection
<!-- Encoding bypasses the mask --> # ❌
# Leave production secrets unprotected
<!-- Feature branch MRs can exfiltrate them --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Variable empty on feature branch | Protected variable | Run on protected branch |
| Secret in job log | Not masked | Mark as masked |
| Masked value printed | Encoded/split | Do not encode secrets |
| Variable not available | Wrong environment scope | Check job environment |
| Predefined variable empty | Wrong pipeline type | Check pipeline source |
Real-World Examples
1. Predefined Commit
script:
- echo $CI_COMMIT_SHA
2. Masked Variable
Settings → CI/CD → Variables → Masked: Yes
3. Protected Variable
Settings → CI/CD → Variables → Protected: Yes
4. Environment Scope
Environment scope: production
5. Use in Job
script:
- ./deploy.sh --token $DEPLOY_TOKEN
6. CI_JOB_TOKEN
script:
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
7. Branch Condition
rules:
- if: $CI_COMMIT_BRANCH == "main"
8. Tag Condition
rules:
- if: $CI_COMMIT_TAG
9. Job Variable
job:
variables:
LOCAL_VAR: "value"
10. Group Variable
Group → Settings → CI/CD → Variables
Visual
Variable Precedence
┌──────────────────────────────────────────────┐
│ Job-level YAML (highest) │
└──────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Pipeline variables │
└──────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Project variables │
└──────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Group variables │
└──────────────────┬───────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ Instance variables (lowest) │
└──────────────────────────────────────────────┘
Masked and Protected
┌──────────────────────────────────────────────┐
│ Variable: DEPLOY_TOKEN │
│ │
│ Masked: Yes │
│ → Job log shows [MASKED] │
│ │
│ Protected: Yes │
│ → Only on protected branches/tags │
│ → Not on feature branch MRs │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ main branch → token available │
│ feature branch → token empty │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Predefined variable prefix | CI_ |
| Project variables location | Settings → CI/CD → Variables |
| Masked | Hides value in logs |
| Protected | Restricts to protected branches/tags |
| Environment scope | Limits to specific environments |
| Masking minimum | 8 characters |
| Masking bypass | Encoding (base64, hex) |
| CI_JOB_TOKEN | Short-lived registry token |
Key takeaways:
- Predefined variables describe the pipeline. The
CI_COMMIT_SHA,CI_PIPELINE_ID, andCI_PROJECT_DIRvariables are injected by GitLab and cannot be modified. They provide context for the job . - Project variables store configuration and secrets. They are defined in the settings, not in the YAML. The
maskedsetting hides the value in logs. Theprotectedsetting restricts the variable to protected branches and tags . - Masking is not encryption. The value must be at least 8 characters and cannot contain newlines. Encoding the value bypasses the mask. The
protectedsetting is the primary defense against exfiltration . - Environment scope allows per-environment values. A variable scoped to
productionis only available to jobs that declareenvironment: production. This is the mechanism for different values in staging and production. - The variable precedence is hierarchical. Job-level YAML overrides pipeline variables, which override project variables, which override group variables, which override instance variables .
Remember: Variables are the configuration layer. Secrets are variables that must never appear in logs or be accessible from untrusted branches. The masked setting hides the value. The protected setting restricts access. The environment scope limits the context. The precedence order determines the final value. For high-value secrets, a dedicated secrets manager is the correct solution.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!