Variables & Secrets

A complete guide to GitLab CI/CD variables and secrets. Learn CI/CD variables, masked variables, protected variables, file variables, and security best practices.

Variables Masked Protected File Variables
What are CI/CD Variables?

CI/CD variables are dynamic values that are injected into your pipeline at runtime. They're the primary mechanism for configuring pipelines without hardcoding values, and they're absolutely essential for handling secrets like API keys, database passwords, and TLS certificates securely. Every time a job runs, GitLab assembles a set of environment variables from multiple sources and makes them available to your scripts.

Understanding variables is critical because they control almost every aspect of a pipeline's behavior. They determine which Docker registry you push to, which Kubernetes namespace you deploy into, what API endpoints your tests hit, and which credentials your deployment scripts use. A misconfigured variable can break a pipeline, leak a secret, or deploy code to the wrong environment. Getting them right is fundamental to reliable, secure CI/CD.

GitLab provides several types of variables, each with different scoping and security properties. Predefined variables are set by GitLab automatically. Custom variables are defined by you in the .gitlab-ci.yml file or in the GitLab UI. Protected variables are only available on protected branches. Masked variables have their values hidden in job logs. Understanding the distinction between these types is essential for building secure pipelines.

Key Concept: Variables in GitLab CI/CD are environment variables. Your scripts access them the same way they'd access any other shell variable — using $VAR or ${VAR} syntax. The difference is where they come from and how GitLab manages their security.
Types of CI/CD Variables

Predefined Variables

Set automatically by GitLab. Include commit SHA, branch name, pipeline ID, project info, and more.
$CI_COMMIT_SHA

Custom Variables

Defined by you in .gitlab-ci.yml or in GitLab UI. Available at pipeline, stage, or job scope.
variables: { MY_VAR: value }

Masked Variables

Values are hidden in job logs. Essential for secrets that shouldn't appear in CI output.
Value replaced with [MASKED]

Protected Variables

Only available on protected branches and tags. Prevents feature branches from accessing production secrets.
Only on main, release/*

File Variables

Value is stored as a file. The variable contains the path to that file. Perfect for certificates, keys, and config files.
$KUBECONFIG → /path/to/file

Environment Variables

Variables scoped to specific deployment environments. Can be set per environment in GitLab UI.
Production-specific secrets
Predefined Variables

GitLab provides dozens of predefined variables that are automatically available in every pipeline. These variables give you access to information about the commit, branch, pipeline, project, and environment. They're essential for writing dynamic pipelines that adapt to the context in which they run — for example, using the commit SHA as an image tag, or deploying to different environments based on the branch name.

Predefined variables are read-only — you can't overwrite them. They follow a consistent naming convention: they all start with CI_ and use uppercase with underscores. Learning the most common ones will save you significant time and make your pipelines more robust.

# Common Predefined Variables # Commit-related $CI_COMMIT_SHA # Full commit SHA $CI_COMMIT_SHORT_SHA # First 8 characters of SHA $CI_COMMIT_BRANCH # Branch name $CI_COMMIT_TAG # Tag name (empty if not a tag) $CI_COMMIT_REF_NAME # Branch or tag name $CI_COMMIT_REF_SLUG # Lowercased, slugified ref name $CI_COMMIT_MESSAGE # Commit message $CI_COMMIT_TITLE # Commit title (first line) $CI_COMMIT_DESCRIPTION # Commit description $CI_COMMIT_AUTHOR # Commit author $CI_COMMIT_TIMESTAMP # Commit timestamp # Pipeline-related $CI_PIPELINE_ID # Pipeline ID $CI_PIPELINE_IID # Project-level pipeline ID $CI_PIPELINE_SOURCE # Pipeline trigger source $CI_PIPELINE_CREATED_AT # Pipeline creation time $CI_PIPELINE_URL # Pipeline URL # Job-related $CI_JOB_ID # Job ID $CI_JOB_NAME # Job name $CI_JOB_STAGE # Job stage $CI_JOB_STATUS # Job status $CI_JOB_URL # Job URL $CI_JOB_TOKEN # Job token for API access $CI_JOB_STARTED_AT # Job start time $CI_JOB_MANUAL # Whether job is manual # Project-related $CI_PROJECT_ID # Project ID $CI_PROJECT_NAME # Project name $CI_PROJECT_PATH # Project path (group/project) $CI_PROJECT_PATH_SLUG # Slugified project path $CI_PROJECT_NAMESPACE # Project namespace $CI_PROJECT_URL # Project URL $CI_PROJECT_DIR # Project directory $CI_PROJECT_TITLE # Project title # Registry-related $CI_REGISTRY # Container registry URL $CI_REGISTRY_IMAGE # Registry image path $CI_REGISTRY_USER # Registry username $CI_REGISTRY_PASSWORD # Registry password $CI_REGISTRY_IMAGE # Full image path # Server-related $CI_SERVER_URL # GitLab server URL $CI_SERVER_HOST # GitLab server host $CI_SERVER_PORT # GitLab server port $CI_SERVER_PROTOCOL # GitLab server protocol $CI_SERVER_VERSION # GitLab version $CI_SERVER_REVISION # GitLab revision # Merge request-related $CI_MERGE_REQUEST_ID # MR ID $CI_MERGE_REQUEST_IID # Project-level MR ID $CI_MERGE_REQUEST_SOURCE_BRANCH # Source branch $CI_MERGE_REQUEST_TARGET_BRANCH # Target branch $CI_MERGE_REQUEST_TITLE # MR title $CI_MERGE_REQUEST_DESCRIPTION # MR description $CI_MERGE_REQUEST_LABELS # MR labels # Runner-related $CI_RUNNER_ID # Runner ID $CI_RUNNER_DESCRIPTION # Runner description $CI_RUNNER_TAGS # Runner tags $CI_RUNNER_EXECUTABLE_ARCH # Runner architecture # Environment-related $CI_ENVIRONMENT_NAME # Environment name $CI_ENVIRONMENT_SLUG # Environment slug $CI_ENVIRONMENT_URL # Environment URL $CI_ENVIRONMENT_ACTION # Environment action # Example: Using predefined variables in a script build: script: - echo "Commit: $CI_COMMIT_SHORT_SHA" - echo "Branch: $CI_COMMIT_BRANCH" - echo "Pipeline: $CI_PIPELINE_ID" - echo "Project: $CI_PROJECT_PATH" - echo "Registry: $CI_REGISTRY_IMAGE"
Variable Description Example Value
$CI_COMMIT_SHA Full commit SHA a1b2c3d4e5f6...
$CI_COMMIT_SHORT_SHA Short commit SHA a1b2c3d4
$CI_COMMIT_BRANCH Branch name main
$CI_PIPELINE_ID Pipeline ID 12345
$CI_JOB_ID Job ID 67890
$CI_PROJECT_PATH Project path mygroup/myproject
$CI_REGISTRY_IMAGE Registry image path registry.gitlab.com/mygroup/myproject
Custom Variables in .gitlab-ci.yml

Custom variables can be defined directly in your .gitlab-ci.yml file. This is the most common way to define non-sensitive configuration values that don't need to be hidden — things like application names, default versions, or feature flags. Variables defined this way are visible in the repository, so they must never contain secrets.

Variables can be defined at three scopes: pipeline scope (top-level variables keyword), job scope (inside a specific job), and global scope (via default:variables). Job-scoped variables override pipeline-scoped variables with the same name, which gives you fine-grained control over each job's configuration.

Variables can also reference other variables — GitLab expands them at runtime. This lets you build complex values from simple pieces, like combining a registry URL with an image name and version. Just be careful about recursive references, which GitLab will reject.

# Global variables (pipeline scope) variables: NODE_VERSION: "18" APP_NAME: "my-app" DOCKER_REGISTRY: "registry.gitlab.com" DOCKER_IMAGE: "$DOCKER_REGISTRY/$CI_PROJECT_PATH" KUBE_NAMESPACE: "default" # Job-level variables (override global) build: stage: build variables: BUILD_ENV: "production" OPTIMIZE: "true" script: - echo "Building $APP_NAME for $BUILD_ENV" # Variable expansion variables: BASE_URL: "https://example.com" API_URL: "${BASE_URL}/api" FULL_IMAGE: "${DOCKER_REGISTRY}/${APP_NAME}:${CI_COMMIT_SHORT_SHA}" # Default variables (all jobs) default: variables: LOG_LEVEL: "info" before_script: - echo "Log level: $LOG_LEVEL" # Using variables in scripts deploy: script: - echo "Deploying $APP_NAME" - echo "To namespace $KUBE_NAMESPACE" - echo "Using image $DOCKER_IMAGE" # Variables with defaults optional_job: script: - echo "${MY_VAR:-default_value}" # Variables in strings build_image: script: - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA . - docker push $DOCKER_IMAGE:$CI_COMMIT_SHORT_SHA # Multiline variables (block scalar) variables: MULTILINE_VAR: | line 1 line 2 line 3 # Variables with special characters variables: SPECIAL_VAR: "value with spaces" JSON_VAR: '{"key": "value"}' # Variables from command substitution dynamic_job: script: - export TIMESTAMP=$(date +%Y%m%d) - echo "Today is $TIMESTAMP"
Variable Priority: From highest to lowest:
  1. Trigger variables (via API)
  2. Manual pipeline variables (via UI)
  3. Project variables (via UI)
  4. Group variables (via UI)
  5. Instance variables (via UI)
  6. Job-level variables (in .gitlab-ci.yml)
  7. Pipeline-level variables (in .gitlab-ci.yml)
  8. Predefined variables
Masked Variables: Hiding Sensitive Values

Masked variables are the most basic form of secret protection in GitLab. When you mark a variable as masked, GitLab replaces its value with [MASKED] in job logs. This prevents accidental disclosure of secrets through echo statements, error messages, or debug output. It's not a perfect security mechanism — it's a safety net — but it's essential for any value that should never appear in a log.

Masked variables have specific requirements that you must satisfy for the masking to work. The value must be a single line, at least 8 characters long, and must not contain whitespace or certain special characters. These restrictions exist because GitLab uses a pattern-matching approach to mask — it scans job logs for the exact value and replaces matches. If the value is too short or contains unusual characters, the masking might fail or be unreliable.

To configure a masked variable, go to your project's Settings → CI/CD → Variables, click "Add variable," and check the "Masked" option. Alternatively, you can use the GitLab API to create masked variables programmatically. Remember: masking only protects values in job logs. It doesn't encrypt them, doesn't hide them from project maintainers, and doesn't prevent them from being exfiltrated by malicious code in a pipeline.

# Creating a masked variable in GitLab UI # Settings → CI/CD → Variables → Add Variable # # Key: DB_PASSWORD # Value: supersecretpassword123 # Type: Variable # Masked: ✓ (checked) # Protected: (optional) # Using a masked variable in a script deploy: script: - echo "Connecting to database..." # OK - echo "$DB_PASSWORD" # Logs show [MASKED] - mysql -u admin -p$DB_PASSWORD # Password hidden in logs # What masking looks like in logs: # Connecting to database... # [MASKED] # Warning: Using a password on the command line interface can be insecure. # Masked variable requirements: # ✓ Single line (no newlines) # ✓ At least 8 characters long # ✓ No whitespace # ✓ No special characters: @, :, ?, &, etc. # ✓ No base64 padding (=) # Valid masked values: # - mypassword123 # - AbCdEf123456 # - token_ABCdef123456 # Invalid masked values: # - short (too short) # - "with spaces" (contains whitespace) # - "value@with:chars" (special characters) # - "multiline\nvalue" (newlines) # Creating masked variables via API curl --request POST \ --header "PRIVATE-TOKEN: " \ "https://gitlab.com/api/v4/projects/1/variables" \ --form "key=DB_PASSWORD" \ --form "value=supersecret123" \ --form "masked=true" \ --form "protected=true" # Best practice: Use masked for all secrets # - API tokens # - Database passwords # - Private keys (use file variables instead) # - OAuth secrets # - Webhook URLs
Masking Limitations:
  • Masking only hides the exact value in logs — it doesn't encrypt or secure the variable
  • Project maintainers can still see the value in Settings
  • Malicious pipeline code can still exfiltrate the secret
  • Masking can fail if the value is transformed (base64, URL-encoded, etc.)
  • Not suitable for multi-line values (use file variables instead)
  • Combine with protected variables for production secrets
Protected Variables: Environment-Specific Secrets

Protected variables take secret protection to the next level. A protected variable is only available to pipelines running on protected branches or protected tags. This means you can safely store production credentials in a protected variable, knowing that feature branches and merge requests can't access them. It's a fundamental security control for production CI/CD.

The reasoning behind protected variables is straightforward: feature branches are where developers experiment, where unreviewed code runs, and where malicious code could be introduced. If a feature branch could access production database credentials, a compromised developer account could exfiltrate them simply by pushing code that echoes the secret. By marking production secrets as protected, you create a hard boundary: only code that has passed review and merged to a protected branch can access these secrets.

Protected variables work together with protected branches. By default, a new GitLab project has a single protected branch — main (or master, depending on GitLab version). You can add additional protected branches (like release/*) and protected tags. Any branch or tag marked as protected will have access to protected variables; any other branch will see the variable as undefined.

# Configuring protected variables in GitLab UI # Settings → CI/CD → Variables → Add Variable # # Key: PROD_DB_PASSWORD # Value: production-secret-password # Type: Variable # Protected: ✓ (checked) # Masked: ✓ (checked) # # This variable is only available on protected branches # Configuring protected branches # Settings → Repository → Protected Branches # - main (protected by default) # - release/* (custom pattern) # - production (custom branch) # Using protected variables in deploy deploy_production: stage: deploy script: - echo "Deploying to production..." - echo "Password: $PROD_DB_PASSWORD" # Only works on protected branches environment: name: production only: - main # When a non-protected branch tries to use it: # The variable is undefined deploy_feature: script: - echo "$PROD_DB_PASSWORD" # Empty on feature branches # Protected variable behavior: # - Protected branch → Variable available # - Non-protected branch → Variable undefined # - Protected tag → Variable available # - Non-protected tag → Variable undefined # - Merge request from fork → Variable undefined # - Pipeline on fork → Variable undefined # Best practice: Use protected for production # - Database credentials # - Cloud provider API keys # - Production API tokens # - SSL certificates # - Any production secret # Setting up protected environments # Settings → CI/CD → Protected Environments # Allows only specific users/roles to deploy to # production environments # Using environment-scoped variables # Settings → CI/CD → Variables # Set Environment scope: production # Variable available only when deploying to production
Protected Variable Best Practices:
  • Mark all production secrets as protected
  • Combine with masked for defense in depth
  • Protect your main branch (and release branches)
  • Use protected environments for deployment approval
  • Audit protected variable access regularly
  • Rotate production secrets periodically
File Variables: Storing Multi-line Secrets

File variables solve a problem that masked variables can't: multi-line secrets. Things like TLS certificates, SSH private keys, JSON service account credentials, and kubeconfig files are inherently multi-line. They can't be masked (masking requires single-line values), and passing them as environment variables is awkward and error-prone. File variables solve this by storing the value as a file on the runner and exposing the file's path through the variable.

When you create a file variable, GitLab writes the value to a temporary file on the runner and sets the environment variable to that file's path. Your script then reads the file with standard file operations. This is elegant because it works naturally with tools that expect file paths — kubectl --kubeconfig=$KUBECONFIG, docker login --password-stdin < $DOCKER_PASSWORD_FILE, and similar patterns.

File variables are perfect for anything that is naturally a file: certificates, keys, configuration files, JSON credentials, and archives. They're also a security best practice for SSH keys — storing an SSH key in a file variable and using it via $SSH_KEY_FILE avoids the common pitfall of echoing the key to disk with echo "$SSH_KEY" > key, which can leak the key into logs.

# Creating a file variable in GitLab UI # Settings → CI/CD → Variables → Add Variable # # Key: KUBECONFIG # Value: | # apiVersion: v1 # kind: Config # clusters: # - cluster: # server: https://k8s.example.com # name: production # ... # Type: File # Protected: ✓ # Using a file variable deploy_kubernetes: stage: deploy script: # $KUBECONFIG contains the path to the temp file - echo "Using kubeconfig at $KUBECONFIG" - kubectl --kubeconfig=$KUBECONFIG get pods - kubectl --kubeconfig=$KUBECONFIG apply -f deployment.yaml # SSH key as a file variable deploy_via_ssh: stage: deploy script: - mkdir -p ~/.ssh # $SSH_PRIVATE_KEY contains the path to the key file - cp $SSH_PRIVATE_KEY ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh -o StrictHostKeyChecking=no user@server "deploy.sh" # Docker registry credentials as file variable login_docker: stage: build script: # $DOCKER_CONFIG contains path to config.json - mkdir -p ~/.docker - cp $DOCKER_CONFIG ~/.docker/config.json - docker pull myregistry.com/myimage:latest # TLS certificate as file variable deploy_with_tls: stage: deploy script: # $TLS_CERT and $TLS_KEY contain paths to cert files - cp $TLS_CERT /etc/nginx/ssl/cert.pem - cp $TLS_KEY /etc/nginx/ssl/key.pem - nginx -s reload # JSON service account as file variable deploy_gcp: stage: deploy script: # $GCP_SA_KEY contains path to service account JSON - gcloud auth activate-service-account --key-file=$GCP_SA_KEY - gcloud container clusters get-credentials my-cluster # File variable vs Variable: # # Variable (Type: Variable): # - Value is the literal string # - $VAR contains the value # - echo $VAR prints the value # - Suitable for single-line values # - Can be masked # # File (Type: File): # - Value is written to a temp file # - $VAR contains the file path # - cat $VAR prints the value # - Suitable for multi-line values # - Cannot be masked (path is exposed) # Best practice: use file variables for # - SSH private keys # - TLS certificates # - Kubeconfig files # - JSON service account keys # - Multi-line configuration files
File Variables vs Regular Variables:
  • Regular Variable: $VAR contains the actual value
  • File Variable: $VAR contains the path to a temp file
  • Use file variables for multi-line content, certificates, and keys
  • Use regular variables for single-line values (can be masked)
  • File variables can't be masked (the path is visible), but the contents are only in the temp file
Security Best Practices

Never Commit Secrets

Never store secrets in .gitlab-ci.yml or any file in the repository. Use GitLab CI/CD variables or an external secret manager.

Mask All Secrets

Mark every sensitive variable as masked. Even if you don't think the value will be logged, masking prevents accidental disclosure.

Protect Production Secrets

Mark production secrets as protected. This ensures feature branches and forks can't access them.

Use File Variables for Keys

Use file variables for SSH keys, TLS certificates, kubeconfig files, and other multi-line secrets.

Rotate Secrets Regularly

Rotate API keys, database passwords, and tokens on a regular schedule. Automate rotation where possible.

Audit Variable Access

Monitor who accesses variables and when. Set up alerts for suspicious access patterns.

Use External Secret Managers

For high-security environments, integrate with Vault, AWS Secrets Manager, or GCP Secret Manager.

Limit Variable Scope

Use environment-scoped variables to limit secrets to specific environments. Don't give production secrets to staging jobs.
Security Incidents to Avoid:
  • Committing a secret to the repository and pushing it
  • Logging a secret without masking it
  • Allowing feature branches to access production secrets
  • Sharing secrets between environments unnecessarily
  • Using the same secret across multiple projects
  • Failing to rotate secrets after a team member leaves
  • Storing secrets in job artifacts or cache
Complete Example: Variables in a Real Pipeline
# Complete example showing all variable types stages: - build - test - deploy # Global variables (non-sensitive) variables: NODE_VERSION: "18" APP_NAME: "my-app" DOCKER_REGISTRY: "$CI_REGISTRY" DOCKER_IMAGE: "$CI_REGISTRY_IMAGE" # Global defaults default: image: node:$NODE_VERSION before_script: - echo "Starting job $CI_JOB_NAME" - echo "Running in pipeline $CI_PIPELINE_ID" - echo "On branch $CI_COMMIT_BRANCH" # Build stage (uses global variables) build: stage: build script: - npm ci - npm run build artifacts: paths: - dist/ cache: key: files: - package-lock.json paths: - node_modules/ # Test stage (uses masked variable for DB password) test: stage: test variables: TEST_ENV: "ci" services: - postgres:15 variables: POSTGRES_DB: testdb POSTGRES_USER: testuser # POSTGRES_PASSWORD is set as a masked variable # in GitLab UI (Settings → CI/CD → Variables) script: - echo "Running tests in $TEST_ENV" - echo "Database: $POSTGRES_DB" - echo "Connecting with password..." # Never print the password - npm test # Deploy stage (uses protected variables) deploy_staging: stage: deploy environment: name: staging url: https://staging.example.com script: - echo "Deploying to staging" - echo "Using API token: $STAGING_API_TOKEN" # Masked - deploy.sh staging only: - develop deploy_production: stage: deploy environment: name: production url: https://example.com script: # These are protected variables — only on protected branches - echo "Deploying to production" - echo "Using API token: $PROD_API_TOKEN" # Masked - echo "Using kubeconfig: $KUBECONFIG" # File variable - kubectl --kubeconfig=$KUBECONFIG apply -f k8s/ - deploy.sh production when: manual only: - main # Variables configured in GitLab UI (not in this file): # # Settings → CI/CD → Variables: # ┌─────────────────────┬────────────┬───────────┬──────────┐ # │ Key │ Type │ Masked │ Protected│ # ├─────────────────────┼────────────┼───────────┼──────────┤ # │ STAGING_API_TOKEN │ Variable │ ✓ │ ✗ │ # │ PROD_API_TOKEN │ Variable │ ✓ │ ✓ │ # │ PROD_DB_PASSWORD │ Variable │ ✓ │ ✓ │ # │ KUBECONFIG │ File │ ✗ │ ✓ │ # │ SSH_PRIVATE_KEY │ File │ ✗ │ ✓ │ # │ DOCKER_AUTH_CONFIG │ File │ ✗ │ ✓ │ # │ SSL_CERT │ File │ ✗ │ ✓ │ # │ SSL_KEY │ File │ ✗ │ ✓ │ # └─────────────────────┴────────────┴───────────┴──────────┘ # # Behavior: # - STAGING_API_TOKEN available on develop branch (not protected) # - PROD_API_TOKEN only available on main (protected) # - KUBECONFIG file created only on protected branches # - All masked variables show [MASKED] in logs
What This Example Demonstrates:
  • Global variables for non-sensitive config
  • Job variables for job-specific overrides
  • Masked variables for API tokens and passwords
  • Protected variables for production secrets
  • File variables for kubeconfig and SSH keys
  • Environment-scoped for staging vs production
  • Predefined variables used throughout
Frequently Asked Questions
What's the difference between masked and protected variables?
Masked hides the value in job logs. Protected restricts the variable to protected branches and tags. They're independent — you can have a variable that's masked but not protected, protected but not masked, or both.
Can I mask multi-line values?
No. Masked variables must be single-line. For multi-line values like certificates or SSH keys, use file variables instead.
What's the minimum length for a masked variable?
The value must be at least 8 characters long. This prevents masking from interfering with legitimate short strings like "true" or "yes".
Where should I store secrets?
Use GitLab CI/CD variables in the GitLab UI (Settings → CI/CD → Variables). Never commit secrets to the repository. For high-security needs, integrate with Vault, AWS Secrets Manager, or GCP Secret Manager.
Can I override a protected variable?
No. Protected variables are read-only at the pipeline level. You can define a variable with the same name in .gitlab-ci.yml, but the UI variable takes precedence.
How do I access variables in a script?
Use standard shell syntax: $VAR, ${VAR}, or "$VAR". Always quote variables to handle special characters and prevent word splitting.
What's the difference between variables and file variables?
Variables store the literal value — $VAR contains the string. File variables store the value in a temp file — $VAR contains the file path. Use file variables for multi-line content.
How do I pass variables to a child pipeline?
Use the trigger keyword with variables inside the child pipeline trigger. Variables are inherited by default, or you can define new ones.
Previous: Pipeline Stages & Jobs Next: Rules & Conditional Pipelines

Variables and secrets are the configuration backbone of your CI/CD pipelines. Master them to build secure, flexible, and maintainable pipelines.