GitLab CI/CD Variables Explained Properly
The three checkboxes when creating a GitLab variable look harmless, but the combination you choose for each variable determines the security of the entire deployment process. Protected, Masked and Environment Scope mean three different things that get confused constantly.
Table of Contents
- 1. Protected, Masked and Scope: Three Different Concepts
- 2. Protected Variables: Only on Protected Branches
- 3. Masked Variables: No Plaintext in the Job Log
- 4. Environment Scopes: A Variable per Environment
- 5. File-Type Variables: SSH Keys and .env Files
- 6. Group vs. Project Variables: Controlling Visibility
- 7. Magento Deployment Variables in Practice
- 8. Common Configuration Mistakes
- 9. Variable Types Compared
- 10. Summary
- 11. FAQ
1. Protected, Masked and Scope: Three Different Concepts
The most common misunderstanding around GitLab CI/CD variables is that Protected and Masked mean the same thing. They do not. Protected controls which branches and tags a variable is available on. Masked controls whether the variable's value is shown in the job log or hidden. Environment Scope controls which deployment environments a variable gets injected into. These three dimensions can be configured independently, and together they form the security model.
A concrete example: an SSH private key for production should be Protected (only available on main and tags), Masked (no plaintext in the log) and scoped to the production environment. If any one of these three properties is missing, a gap opens up: without Protected the key ends up in feature-branch pipelines, without Masked it is readable in the job log, without a scope it could affect staging jobs where it is not needed at all.
Understanding these three concepts is the foundation of a secure GitLab deployment process. Teams that mix them up end up configuring variables either too openly, which is a security risk, or too restrictively, which causes pipeline jobs to fail because variables are not available where they are expected. Both mistakes are common in practice and both are easy to avoid once the distinction is clear.
2. Protected Variables: Only on Protected Branches
A Protected Variable is only available in jobs that run on protected branches or protected tags. In GitLab, branches are marked as protected under Settings → Repository → Protected Branches. The typical setup for Magento: main and release/* as protected branches, v* as protected tags. A variable with the Protected flag is not available on feature branches at all, which prevents a developer running a feature-branch pipeline from ever touching production credentials.
The most important use case: production SSH keys, production database passwords and production API keys should always be created as Protected Variables. That makes them invisible on feature branches, even if someone tries to read them out with echo $VARIABLE in a job. Only jobs on main or on tags can see these variables. This restriction is not a convenience feature, it is an active safeguard against credential leakage through malicious or accidental code in merge requests.
# Variables configuration: protection levels for Magento deployment
variables:
# Non-sensitive: available on all branches, no masking needed
DEPLOY_PATH: /var/www/magento
RELEASE_RETENTION: "5"
MAGENTO_LOCALE: de_DE
# Protected + Masked + Production-scoped variables (set in GitLab UI):
# SSH_PRIVATE_KEY : Protected: yes, Masked: yes, Scope: production
# COMPOSER_AUTH : Protected: yes, Masked: yes, Scope: *
# DB_PASSWORD_PROD : Protected: yes, Masked: yes, Scope: production
# DB_PASSWORD_STAGING : Protected: no, Masked: yes, Scope: staging
# Usage in deploy job, SSH_PRIVATE_KEY only injected on protected branches
deploy:production:
stage: deploy
environment:
name: production
rules:
# Only runs on protected tags, Protected Variable will be injected
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/'
before_script:
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
- echo "Deploying to production..."
3. Masked Variables: No Plaintext in the Job Log
Masked Variables are replaced with [MASKED] in the job log whenever their value would otherwise appear in the output. This protects against secrets being accidentally displayed in pipeline logs that other team members can view. It is important to understand that masking is not a complete safeguard: if the secret value appears in a different format in the log, for example base64-encoded or split across lines, it will not be masked. Masking is one layer of security, not an absolute guarantee.
There are technical constraints on Masked Variables: the value must be at least 8 characters long and must not contain newlines. SSH private keys therefore cannot be created directly as Masked Variables, since they contain newlines. The workaround is to base64-encode the key, store it as a Masked Variable and decode it inside the job. In practice this is the most common pattern for handling SSH keys in GitLab pipelines.
4. Environment Scopes: A Variable per Environment
Environment Scopes are the most powerful and most frequently misunderstood feature of GitLab CI/CD variables. A scope determines which environments a variable gets injected into. The wildcard scope * means the variable is available in all environments. A specific scope such as production means the variable is only injected when the job runs in an environment named production.
For Magento deployments this means DEPLOY_HOST can be created twice: once with the value staging.example.com and scope staging, and once with the value production.example.com and scope production. The deploy job automatically receives the correct value depending on which environment it runs in. This eliminates conditional logic in the pipeline configuration and guarantees that staging jobs can never end up talking to production hosts.
# Environment-scoped variables allow the same job definition for both environments
# Variables configured in GitLab UI with scopes:
# DEPLOY_HOST -> staging.example.com (scope: staging)
# DEPLOY_HOST -> production.example.com (scope: production)
# DEPLOY_USER -> deploy (scope: *)
# REDIS_HOST -> 10.0.1.10 (scope: staging)
# REDIS_HOST -> 10.0.2.10 (scope: production)
# Single job definition, correct DEPLOY_HOST injected automatically per environment
.deploy-template: &deploy-template
stage: deploy
before_script:
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | ssh-add -
script:
- echo "Deploying to ${DEPLOY_HOST} as ${DEPLOY_USER}"
- rsync -az ./ "${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/releases/${RELEASE_ID}/"
deploy:staging:
<<: *deploy-template
environment:
name: staging
url: https://staging.example.com
rules:
- if: '$CI_COMMIT_BRANCH == "develop"'
deploy:production:
<<: *deploy-template
environment:
name: production
url: https://shop.example.com
rules:
- if: '$CI_COMMIT_TAG =~ /^v\d+/'
when: manual
5. File-Type Variables: SSH Keys and .env Files
File-Type Variables in GitLab CI/CD do not store their content as an environment variable, they store it as a temporary file whose path gets injected as the variable. This is the correct method for SSH private keys, which contain newlines and therefore cannot be used as normal variables. When an SSH key is created as a File-Type Variable, GitLab writes the key to a temporary file and sets the variable to that file's path, for example /tmp/gitlab-ci-key-XXXX.
The advantage over the base64 workaround is that there is no encoding or decoding in the job script, and no risk of the decoded content ending up in the log. Usage in the job is more direct: ssh-add "$SSH_PRIVATE_KEY", where $SSH_PRIVATE_KEY holds the path to the temporary file. That temporary file is automatically deleted after the job finishes. File-Type Variables are also well suited for composer auth.json and other configuration files with structured content.
6. Group vs. Project Variables: Controlling Visibility
GitLab lets you manage variables on three levels: instance, group and project. Group variables are inherited by every project inside the group, and project variables override group variables with the same name. For Magento agencies running multiple projects, the group level is the right place for shared secrets such as composer auth for private packages or general build-tool credentials.
Project-specific secrets such as production SSH keys, database passwords and API keys belong exclusively at the project level. That prevents one project from accidentally accessing another project's credentials, even when both sit in the same group. The hierarchy is: project variable overrides group variable, group variable overrides instance variable. When names collide, the more specific level always wins.
7. Magento Deployment Variables in Practice
For a typical Magento deployment with staging and production, the following variable scheme emerges: non-sensitive values such as paths and locale settings are defined directly in .gitlab-ci.yml as variables:, so they stay transparent and version-controlled. Semi-sensitive values such as hostnames and the deploy user are created in GitLab as non-protected, non-masked, with an environment scope. Highly sensitive values such as SSH keys, database passwords and API keys are created as Protected, Masked and environment-scoped.
This three-way split has a practical benefit: the pipeline configuration in .gitlab-ci.yml stays readable and traceable, because everything non-sensitive lives there. The GitLab UI only holds the actual secrets. New developers can understand the pipeline logic without needing access to GitLab settings, and changes to non-sensitive values can be tracked through merge requests.
8. Common Configuration Mistakes
The most common mistake is creating production credentials without the Protected flag. That makes them available in feature-branch pipelines, where they could in theory be read out through a merge request containing an echo $VARIABLE line in a script. The second most common mistake is skipping environment scopes because it initially seems simpler to create a variable globally. That leads to staging jobs operating against production hosts or credentials whenever a variable shares a name across environments but was only meant to differ in value.
A third, subtler mistake: SSH keys get created as a normal (non-File-Type) variable and then used in deployment scripts with echo "$SSH_KEY" | ssh-add -. This works in many cases, but it has two problems. The key content may end up in the job log if masking fails because the value is too long or gets split, and the key is expanded directly into the process environment, which can cause whitespace-handling issues under certain shell configurations. File-Type Variables avoid both problems.
9. Variable Types Compared
Choosing the right variable configuration depends on sensitivity and usage context. The table below shows the recommended configuration for typical Magento deployment variables.
| Variable | Protected | Masked | Scope | Type |
|---|---|---|---|---|
| SSH_PRIVATE_KEY (prod) | Yes | n/a | production | File |
| COMPOSER_AUTH | Yes | Yes | * | Variable |
| DEPLOY_HOST | No | No | staging / production | Variable |
| DB_PASSWORD | Yes | Yes | staging / production | Variable |
| DEPLOY_PATH | No | No | * | In .gitlab-ci.yml |
The table illustrates the principle: the more sensitive a variable, the more restrictive its configuration should be. Non-sensitive values belong directly in .gitlab-ci.yml, since they are transparent, version-controlled and do not need to be managed through the GitLab UI at all. Highly sensitive secrets should be Protected, Masked and environment-scoped, so they never show up in any log and stay inaccessible on feature branches.
10. Summary
Protected, Masked and Environment Scopes are three independent security dimensions that together determine the variable security model of a GitLab pipeline. Protected restricts access to protected branches, Masked prevents log output, and scope separates environments. Configure all three correctly and you ensure that staging credentials never land in production jobs, and that production secrets never surface in feature-branch logs.
The practical recommendation: define non-sensitive values directly in .gitlab-ci.yml as variables:. Give environment-specific hostnames and users a scope but no Protected or Masked flags. Make every secret Protected, Masked and environment-scoped. Store SSH keys as File-Type Variables. This system keeps the pipeline configuration understandable, the secrets safe and the environment separation reliable.
Configuring GitLab Variables Correctly: The Essentials at a Glance
Protected
Variable only available on protected branches and tags. Prevents access to production credentials from feature-branch pipelines.
Masked
Value gets replaced with [MASKED] in the job log. Not an absolute safeguard, but it prevents accidental plaintext exposure in pipeline logs.
Environment Scope
Variable is only injected into jobs running in the defined environment. Allows the same variable name to hold different values per environment.
File-Type
Content is stored as a temporary file. Required for SSH keys containing newlines. Avoids whitespace issues and accidental log output.