GitLab CI variables have three types (env var, file, variable), three scopes (project, group, instance), and three protection levels (masked, protected, expanded). The right answer is masked for secrets (the value is hidden in job logs), protected for production (the variable is only exposed to pipelines on protected branches), and file for multi-line values (the value is written to a temp file, the path is in $VAR_NAME). The mistake every team makes: the team sets a variable as masked but the mask regex does not match the value (the value is still visible in the logs), and the team finds out in the post-mortem.
Table of contents
- The three types — env var, file, variable
- The three scopes — project, group, instance
- The three protection levels — masked, protected, expanded
- The mask regex — why your masked variable is still visible
- The CI/CD variables in the .gitlab-ci.yml
- The group inheritance — the team that has the right variable in the wrong place
- The one mistake that leaks the secret
- How this fits the rest of the stack
- FAQ
The three types — env var, file, variable
The first type is env var. The value is injected into the job’s environment as $VAR_NAME. The right answer is env var for most cases — database URLs, API keys, feature flags.
The second type is file. The value is written to a temp file (e.g., /tmp/secret.txt), the path to the file is in $VAR_NAME. The right answer is file for multi-line values (SSH keys, certificates, JSON blobs) and for tools that expect a file path (--key-file /tmp/key).
The third type is variable (the default). The right answer is variable for everything that is not a secret — build flags, image tags, environment names.
The three scopes — project, group, instance
The first scope is project. The variable is set on the project, only available to that project’s pipelines. The right answer is project for project-specific vars (the project’s database URL, the project’s API key).
The second scope is group. The variable is set on a group, available to all projects in the group. The right answer is group for shared infrastructure (the shared Docker registry credentials, the shared Slack webhook). The right answer is to use the most restrictive scope — a group var is exposed to more pipelines than a project var.
The third scope is instance. The variable is set on the GitLab instance, available to all projects. The right answer is instance for instance-wide config (the SMTP server, the license key). The wrong answer is to use instance for project-specific secrets — every project in the instance can see the value.
The three protection levels — masked, protected, expanded
The first protection is masked. The value is hidden in job logs (the CI replaces the value with [MASKED]). The right answer is masked for any value that is a secret (API key, password, token). The gotcha: the mask regex requires the value to be a specific shape (no newlines, no spaces in some cases), and the team that sets a multi-line masked value finds the value is not actually masked.
The second protection is protected. The variable is only exposed to pipelines on protected branches (main, release/*). The right answer is protected for production secrets (the production database URL, the production API key). The wrong answer is unprotected for production secrets — a developer’s branch pipeline has the production database URL, the developer runs a migration, the production database is dropped.
The third protection is expanded. The variable’s value can reference other variables ($OTHER_VAR). The right answer is expanded for compound values (a URL that includes a host and a secret). The wrong answer is expanded for plain secrets — the expansion is a foot-gun, and the team’s secret ends up in the logs.
The mask regex — why your masked variable is still visible
The mask regex requires the value to be a specific shape: alphanumeric with at most 32 characters per chunk, no newlines, no special characters. The team that sets a masked value to a long random string (supersecretvalue_abc123def456) finds the mask does not match — the value is visible in the logs. The right answer is to test the mask before saving the variable (run a test job, check the logs).
The right answer for a multi-line masked value is to use the file type instead of the env var type. The file path is masked, the file contents are not in the logs (the team reads the file in the script, the file is not echoed).
The CI/CD variables in the .gitlab-ci.yml
The team’s .gitlab-ci.yml can define variables at the top level (applied to all jobs), at the job level (applied to that job), and via the variables keyword. The right answer is to use the dashboard for secrets (not the YAML), and the YAML for non-secret values. The wrong answer is to put secrets in the YAML — the YAML is in the repo, the secrets are in the version history forever.
The group inheritance — the team that has the right variable in the wrong place
The group variable is inherited by all projects in the group. The team that sets the production database URL at the group level finds the URL is available to every project in the group, including the team’s staging projects and the team’s experimental projects. The right answer is to set the production URL at the project level, the right answer for shared infrastructure (the Docker registry, the shared Slack webhook) is at the group level.
The gotcha: the project can override the group variable by setting a same-named variable at the project level. The right answer is to use a different name for the override (DATABASE_URL_PROD and DATABASE_URL_STAGING) so the team does not accidentally override the wrong one.
The one mistake that leaks the secret
The mistake: the team sets the production API key as a masked variable, the key has a special character (a : or a = or a /), the mask regex does not match, the key is visible in the logs. The team that does not check the logs is the team that finds out in the post-mortem. The right answer is to test the mask in a sandbox job before saving the variable in the real pipeline.
The right answer is to use a file variable for any secret that does not match the mask regex. The right answer for an SSH key is a file, the right answer for a JSON blob is a file, the right answer for a multi-line token is a file.
How this fits the rest of the stack
The infrastructure question is a small piece of a larger pattern: the team’s runtime, storage, database, secret store, logs, and deployment platform are all parts of the same platform. The right answer is to model the full stack before the project ships, not after. The RunxBuild hosting calculator is the right place to do that exercise — pick the runtime, the memory tier, the storage, the secret store, and the egress, and the calculator shows what the deploy actually costs at the team’s actual usage.
Useful related references:
FAQ
How do I add an environment variable in GitLab CI?
Settings > CI/CD > Variables > Expand > Add Variable. The team sets the key, value, type, and protection levels.
What is the difference between masked and protected variables?
Masked hides the value in job logs. Protected exposes the value only to pipelines on protected branches. The right answer is both for production secrets.
How do I add a file variable in GitLab CI?
Settings > CI/CD > Variables > Add Variable > Type: File. The value is written to a temp file, the path is in $VAR_NAME.
Why is my masked variable still visible in the logs?
The mask regex requires the value to match a specific shape (alphanumeric, no newlines, no special characters). The right answer is to test the mask in a sandbox job or use a file variable for secrets that do not match the regex.
How do I use a variable in .gitlab-ci.yml?
Use $VAR_NAME in the YAML. The right answer is to set the variable in the dashboard, not in the YAML, for secrets.
What is the difference between project, group, and instance variables?
Project variables are set on a single project. Group variables are inherited by all projects in a group. Instance variables are available to all projects on the GitLab instance. Use the most restrictive scope.
Can I override a group variable at the project level?
Yes, set a same-named variable at the project level. The right answer is to use a different name for the override so the team does not accidentally override the wrong one.
How do I rotate a GitLab CI variable?
Edit the variable in the dashboard, the change is applied to the next pipeline run. The right answer is to plan the rotation during a maintenance window and to have a rollback plan.