Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for GitLab CI/CD By James Joyner IV · · 9 min read

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 production is absent in a job whose environment: is staging (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_TOKEN where the variable is named DEPLOY_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

  1. Decide whether the ref should be protected. If DEPLOY_TOKEN is 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_PROTECTED is true on those refs and the protected variable is delivered.

  2. 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.

  3. Align the environment scope. A variable scoped to production only 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.

  4. 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.

  5. 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
  6. 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 production and confirm each deploy job sets a matching environment:.
  • 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 echo a secret; print presence and length only. Masking hides logs, it does not control delivery.
  • For triage, the free incident assistant can map an unbound variable failure to the likely protection/scope cause. More patterns: GitLab CI/CD guides.
  • HTTP Basic: Access denied / Authentication failed — an empty token reaching git/registry auth, often this same root cause.
  • denied: requested access to the resource is denied pushing to CI_REGISTRY — registry credentials missing or unprotected.
  • This job is blocked on 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.

Free download · 368-page PDF

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.