GitLab CI Error Guide: '$DEPLOY_TOKEN: unbound variable' Protected CI/CD Variable Not Available
Fix an empty GitLab CI/CD variable: protect the branch or uncheck Protected, fix the environment scope, and resolve group-vs-project precedence so secrets reach the job.
- #gitlab-cicd
- #troubleshooting
- #errors
- #variables
Exact Error Message
A CI/CD variable you defined in the UI is empty inside the job. With Bash strict mode it surfaces as an unbound-variable failure; without it, a downstream tool reports a missing secret:
$ deploy --token "$DEPLOY_TOKEN"
/scripts/deploy.sh: line 4: DEPLOY_TOKEN: unbound variable
ERROR: Job failed: exit status 1
Other shapes of the same root problem:
$ echo "${DEPLOY_TOKEN:-EMPTY}"
EMPTY
remote: HTTP Basic: Access denied
fatal: Authentication failed
Error: variable DEPLOY_TOKEN is not set
The variable exists in Settings > CI/CD > Variables, yet the job sees nothing. The value was simply not injected into this pipeline run.
What the Error Means
GitLab does not inject every defined variable into every job. A variable is delivered only if its attributes match the pipeline’s context: whether the ref is protected, which environment the job targets, and where the variable is defined (instance, group, project) relative to others with the same key. If any of those gates does not match, the variable is silently omitted — there is no warning, the value is just empty.
So “the variable is set but the job can’t see it” almost always means one of: the variable is Protected but the branch/tag is not protected; the variable is scoped to an environment the job is not deploying to; or a higher-precedence variable with the same key shadowed it. The fix is to align the variable’s attributes with the pipeline, never to paste the secret into .gitlab-ci.yml.
Common Causes
- Variable is “Protected” but the branch/tag is not. Protected variables are exposed only on protected branches and protected tags. A feature branch or an MR pipeline gets nothing.
- Wrong environment scope. A variable scoped to
productionis absent in a job whoseenvironment:isstaging(or has no environment). - Masked-variable rules. A value that cannot be masked (too short, disallowed characters) may have been saved without masking — or masking is confused with availability; masking only hides the value in logs, it does not gate delivery.
- Group vs project precedence. A project-level variable with the same key overrides a group-level one (and instance is lowest). The “missing” value may be shadowed by an empty or different same-named variable.
- Defined in the UI but not exported to MR pipelines. Merge request pipelines from forks, or detached MR pipelines, may not run on a protected ref and so never receive protected variables.
- A typo in the key, in a
variables:block, or expanding$DEPLOY_TOKENwhere the variable is namedDEPLOY_KEY. - Manual / trigger pipeline variable not passed. A variable expected from a manual run or downstream trigger was not provided, so it is genuinely unset.
How to Reproduce the Error
Define a Protected variable, then run a pipeline on an unprotected branch:
Settings > CI/CD > Variables > Add variable
Key: DEPLOY_TOKEN
Value: glpat-xxxxxxxxxxxx
Flags: [x] Protected [x] Masked
Environment scope: production
# .gitlab-ci.yml — runs on every branch
check-token:
script:
- echo "ref protected? ${CI_COMMIT_REF_PROTECTED}"
- echo "token = ${DEPLOY_TOKEN:-EMPTY}"
On a feature branch the log prints ref protected? false and token = EMPTY. On main (protected) with a production environment job, the value appears.
Diagnostic Commands
Never echo a secret in cleartext — print only whether it is present and check the ref/protection context:
debug-vars:
environment:
name: production
script:
# presence only, never the value
- 'echo "DEPLOY_TOKEN set? $([ -n "${DEPLOY_TOKEN:-}" ] && echo yes || echo no)"'
- 'echo "length: ${#DEPLOY_TOKEN}"'
- 'echo "ref protected: ${CI_COMMIT_REF_PROTECTED}"'
- 'echo "branch: ${CI_COMMIT_REF_NAME} env: ${CI_ENVIRONMENT_NAME:-none}"'
DEPLOY_TOKEN set? no
length: 0
ref protected: false
branch: feature/login env: production
In the UI, verify the variable’s attributes line up with the job:
Settings > CI/CD > Variables -> Protected? Masked? Environment scope?
Settings > Repository > Protected branches / Protected tags
Group > Settings > CI/CD > Variables -> same key shadowing the project one?
CI_COMMIT_REF_PROTECTED is the fastest check: if it is false, no protected variable will ever be available in that pipeline.
Step-by-Step Resolution
-
Decide whether the ref should be protected. If
DEPLOY_TOKENis a production secret, protect the branch/tag it deploys from rather than weakening the variable:Settings > Repository > Protected branches > Protect "main" Settings > Repository > Protected tags > Protect "v*"After this,
CI_COMMIT_REF_PROTECTEDistrueon those refs and the protected variable is delivered. -
Or, if the variable does not need protection, uncheck Protected. Then it is available on all branches (including feature branches and MRs). Only do this for non-sensitive values.
-
Align the environment scope. A variable scoped to
productiononly reaches jobs that target that environment:deploy-prod: environment: name: production # must match the variable's Environment scope script: ["./deploy.sh"]Set the variable’s scope to
*if it should apply to every environment. -
Resolve precedence collisions. If a project variable shadows a group one (or vice versa), remove the duplicate or set the correct value at the level you intend. Order, lowest to highest: instance < group < project < pipeline/trigger < job
variables:< manual-run value. -
Pass manual/trigger variables explicitly. For a manually started or downstream-triggered pipeline, provide the value:
trigger-deploy: trigger: group/deploy-project variables: DEPLOY_TOKEN: "$DEPLOY_TOKEN" # forward the protected value downstream -
Fix typos in the key and in any
variables:references, and guard scripts with${VAR:?must be set}so a missing value fails loudly with a clear message instead of silently deploying nothing.
Re-run the pipeline on the correct ref/environment and confirm DEPLOY_TOKEN set? yes.
Prevention and Best Practices
- Treat “protected variable + protected ref” as a pair — protect the branch or tag the secret deploys from, and keep secrets Protected so they never leak onto feature branches.
- Use environment scopes deliberately: scope production secrets to
productionand confirm each deploy job sets a matchingenvironment:. - Avoid same-key collisions across instance/group/project levels; if you must, document which level wins.
- Guard required secrets with
: "${DEPLOY_TOKEN:?DEPLOY_TOKEN is not set}"at the top of deploy scripts so a missing value stops the job immediately. - Never
echoa secret; print presence and length only. Masking hides logs, it does not control delivery. - For triage, the free incident assistant can map an
unbound variablefailure to the likely protection/scope cause. More patterns: GitLab CI/CD guides.
Related Errors
HTTP Basic: Access denied/Authentication failed— an empty token reachinggit/registry auth, often this same root cause.denied: requested access to the resource is deniedpushing toCI_REGISTRY— registry credentials missing or unprotected.This job is blockedon a protected environment — a deployment-permission issue, not a variable one.- See the GitLab CI/CD guides.
Frequently Asked Questions
Why is my variable empty on a feature branch but fine on main? Because it is marked Protected, and protected variables are only injected on protected branches/tags. main is protected; your feature branch is not. Either protect the branch or uncheck Protected (only for non-secrets).
Does “Masked” stop the variable from reaching the job? No. Masking only hides the value in job logs. Availability is controlled by Protected and Environment scope. A masked variable can still be empty if the ref is unprotected.
How do I check protection from inside a job without leaking the secret? Read CI_COMMIT_REF_PROTECTED (true/false) and test presence with [ -n "${VAR:-}" ] or ${#VAR}. Never echo "$VAR" directly.
My group and project both define the variable — which wins? The more specific level wins: project overrides group, and group overrides instance. Pipeline/trigger and job variables: override all of those. A stale project-level duplicate is a common cause of a “missing” value.
Why is the variable missing in a merge request pipeline from a fork? Fork MR pipelines often run on an unprotected ref (and in a restricted context), so protected variables are not exposed. Run the deploy on a protected branch after merge, or use a project access token scoped appropriately.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.