Define the same key in seven places and GitLab CI/CD variables resolve in a fixed order that most teams only learn the hard way. On our test instance a variable named P_A printed project on a normal push and pipeline on an API-triggered run, while a value written to a dotenv report lost to an instance variable it was meant to override. When your job keeps reading the wrong value, the cause is almost always that order, a protected flag on an unprotected branch, or a mask GitLab refused to apply.
This guide covers every variable level, masked and protected secrets, file-type variables, dotenv handoffs, rules: and typed spec:inputs, with the real validation errors each one throws. Every pipeline, job log and error below was run on GitLab CE 19.5.0 and GitLab Runner 19.5.0 (Docker executor) on Ubuntu 26.04 in October 2026, then replayed on a clean VM.
Choose variables or inputs
Use a variable when the value is a secret, depends on where the job runs, or may change after the pipeline starts. Use an input when you choose the value before the pipeline exists. Inputs are resolved once at pipeline creation and type-checked, so a bad value is rejected before a single job is queued. That matters more than the syntax suggests, because a typo in a variable gives you a job that runs with an empty string, while a typo in an input gives you a pipeline that never starts and an error naming the input.
| Question | Variables ($VAR) | Inputs ($[[ inputs.name ]]) |
|---|---|---|
| When is the value fixed? | Expanded while the job runs | Interpolated when the pipeline is created |
| Type checking | None, every value is a string | string, number, boolean, array, plus options and regex |
| Where it can be set | YAML, project, group, instance, pipeline, dotenv | The include: that pulls in a template, or the Run pipeline form, API and schedules for the main file |
| Can it hold a secret? | Yes, masked and protected | No masking, keep secrets out |
| Blocked by “No one allowed” pipeline variables? | Yes | No, we ran an inputs-only pipeline with that setting on |
We measured the last row in our lab, and it’s the practical reason to move user-facing parameters to inputs. You can switch pipeline variables off for everyone and still let people pick a deploy target from a dropdown.
Prerequisites
You need a running GitLab instance and a runner that can pick up jobs. The examples read the job logs of a Docker executor, so paths such as /builds/acme/ci-variables-demo.tmp/ are what that executor prints. A shell executor uses a different build directory but the same variable rules. You’ll need:
- GitLab CE 17.11 or newer, from our Ubuntu 26.04 GitLab guide
- A registered runner, as in the GitLab Runner install guide
- An admin’s personal access token with the
apiscope - Docker on the runner host, from our Docker CE install on Ubuntu 26.04
curl,gitandjqon your workstation
On Ubuntu 24.04 or Debian 13, the older GitLab CE install guide covers the same setup. Either way you end up with the same GitLab, so every API call below works unchanged.
For the runner, the Docker executor steps in our Docker build and Container Registry guide produce exactly the runner used here. That runner needs Docker on its host, which the prerequisite list covers.
Our lab was a single 4 vCPU, 8 GB VM running GitLab and the runner together. Treat that as a floor for following along, because GitLab’s memory use grows with concurrent users and Sidekiq load, and the runner wants its own host once more than a couple of jobs run at the same time.
This page assumes you already know stages and jobs. For a wider grounding in CI/CD design, the books we recommend for learning CI/CD pipelines cover the theory this page skips.
Set reusable shell variables
Every API call below needs your GitLab hostname and token, so export them once. Create the token under your avatar, Edit profile > Access > Personal access tokens > Generate token > Legacy token, tick api, and paste it in place of the placeholder. The api scope belongs to legacy tokens, so a fine-grained token won’t work here:
export SITE_DOMAIN="gitlab.example.com"
export GL_TOKEN="glpat-your-token-here"
Exports last only for this shell, so run them again after you reconnect. Check that the token works before going further, because a wrong token makes every later call return 401 Unauthorized and nothing else.
curl -s --header "PRIVATE-TOKEN: ${GL_TOKEN}" "https://${SITE_DOMAIN}/api/v4/version" | jq '{version}'
A healthy instance reports its version. A certificate error at this point means your workstation doesn’t trust the GitLab certificate yet.
{
"version": "19.5.0"
}
Group variables need a group, so create one called acme and a project inside it, then keep both IDs for the calls that follow. If you already have a group you’d rather use, change acme in the first two lines and in the clone URL later.
export GROUP_ID=$(curl -s --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/groups/acme" | jq -r '.id // empty')
[ -z "${GROUP_ID}" ] && export GROUP_ID=$(curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/groups" --data "name=acme" --data "path=acme" | jq -r .id)
export PROJECT_ID=$(curl -s --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/acme%2Fci-variables-demo" | jq -r '.id // empty')
[ -z "${PROJECT_ID}" ] && export PROJECT_ID=$(curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects" --data "name=ci-variables-demo" \
--data "namespace_id=${GROUP_ID}" --data "initialize_with_readme=true" | jq -r .id)
echo "group ${GROUP_ID}, project ${PROJECT_ID}"
Both lookups reuse an existing acme group and ci-variables-demo project, and only create them when they are missing, so the block is safe to run again. Two numbers mean both objects exist.
group 2, project 1
Keep this shell open. Every call in the next sections uses GROUP_ID and PROJECT_ID, and a new shell needs them again, which is what re-running the same block gives you.
Add project, group and instance variables
Settings variables live outside the repository, which is exactly why they exist, since tokens and per-environment values never get committed. To see the precedence order rather than read about it, we gave eight keys, P_A to P_H, values at different levels, each named after the level that set it. Start with the instance level, which needs an administrator token and lives under Admin > Settings > CI/CD > Variables in the UI.
for k in P_A P_B P_C P_D; do
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/admin/ci/variables" \
--data "key=${k}" --data "value=instance" | jq -c '{key, value}'
done
Then add the group and project levels, each overriding fewer keys than the level above it. In the UI both sit under Settings > CI/CD > Variables of the group or project.
for k in P_A P_B P_C; do
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/groups/${GROUP_ID}/variables" \
--data "key=${k}" --data "value=group" | jq -c '{key, value}'
done
for k in P_A P_B; do
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/variables" \
--data "key=${k}" --data "value=project" | jq -c '{key, value}'
done
Each call echoes the key and value it stored. A line showing "key": null was rejected, and running the call again without the jq filter shows why.
{"key":"P_A","value":"instance"}
{"key":"P_B","value":"instance"}
{"key":"P_C","value":"instance"}
{"key":"P_D","value":"instance"}
{"key":"P_A","value":"group"}
{"key":"P_B","value":"group"}
{"key":"P_C","value":"group"}
{"key":"P_A","value":"project"}
{"key":"P_B","value":"project"}
The YAML file adds the remaining levels later: global variables:, job variables:, rules:variables, a dotenv report and one pipeline variable. Group variables are inherited by every project in the group and its subgroups, with the closest subgroup winning, so they suit values such as a shared registry mirror or a notification channel. Instance variables reach every project on the server, which makes them handy for a proxy URL and dangerous for anything secret.
Mask and protect secrets
A secret needs three decisions, and each one maps to a flag you set when you create the variable:
- Masked: hidden in job logs
- Masked and hidden: also unreadable in settings
- Protected: only exported on protected branches and tags
Create one variable of each kind. The raw=true flag is the API side of the UI’s Expand variable reference checkbox: it stops GitLab from treating $ inside the value as a reference, and the docs recommend clearing that checkbox for masked values. Variables we created through the API without it showed up with an “Expanded” badge in the UI, so pass it explicitly. You’ll see what each variable prints once the demo pipeline below runs.
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/variables" \
--data "key=API_TOKEN" --data "value=lab-Secret-9f3k2Qx7" \
--data "masked=true" --data "raw=true" | jq -c '{key, masked, protected, hidden}'
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/variables" \
--data "key=PROD_PASSWORD" --data "value=Prod-Pass-7Hq2Lm4W" \
--data "masked=true" --data "protected=true" --data "raw=true" | jq -c '{key, masked, protected, hidden}'
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/variables" \
--data "key=SIGNING_KEY" --data "value=hidden-Key-3Rt8Pz6N" \
--data "masked=true" --data "masked_and_hidden=true" --data "raw=true" | jq -c '{key, value, masked, protected, hidden}'
Look at what the third line returns. A masked and hidden variable comes back with "value": null on create and on every later read, through the API and the UI alike, so store the real value somewhere else before you save it.
{"key":"API_TOKEN","masked":true,"protected":false,"hidden":false}
{"key":"PROD_PASSWORD","masked":true,"protected":true,"hidden":false}
{"key":"SIGNING_KEY","value":null,"masked":true,"protected":false,"hidden":true}
Masking has rules, and GitLab enforces them at save time. In the UI the drawer refuses the value and tells you why, while the API returns only a terse {"message":{"value":["is invalid"]}}. We typed abc 123 into a masked variable to show both rules failing at once.

The value must be a single line of at least 8 characters with no spaces, and it cannot match the name of another variable. When expansion is left on, the docs also limit the non-alphanumeric characters to _ : @ - + . ~ = /, which is the other reason to save masked values with expansion off. When a real token breaks those rules, such as a JSON blob or a multi-line key, don’t mask it at all and switch to a file-type variable, covered in the next section.
The next two outputs come from the secrets job of the demo pipeline you create further down. A job on main that echoes all three shows the masking working. main is protected by default, so the protected password is present and masked too:
$ echo "API_TOKEN=$API_TOKEN"
API_TOKEN=[MASKED]
$ echo "SIGNING_KEY=$SIGNING_KEY"
SIGNING_KEY=[MASKED]
$ echo "PROD_PASSWORD=${PROD_PASSWORD:-<empty on this ref>}"
PROD_PASSWORD=[MASKED]
$ echo "API_TOKEN length is ${#API_TOKEN}"
API_TOKEN length is 19
That last line is why masking is a log filter and not access control. The job has the full 19-character value and can do anything with it, including send it somewhere or print a transformed copy that the mask does not recognise. The same job on a feature-x branch, which isn’t protected and which you push after the first pipeline, gets no password at all:
$ echo "PROD_PASSWORD=${PROD_PASSWORD:-<empty on this ref>}"
PROD_PASSWORD=<empty on this ref>
That empty value is the most common “my variable is missing” report. GitLab doesn’t warn you; the variable simply is not exported, so a deploy job on a feature branch connects with an empty password and fails somewhere far from the cause. Check CI_COMMIT_REF_PROTECTED in the job, which printed true on main and false on feature-x in our runs.

You’re looking at the secrets job from the first pipeline in the GitLab UI, with the runner’s timestamps on the left. If you ever see a secret in clear text in a log like this, rotate it first and fix the mask second, because job logs are kept and often readable by every developer on the project.
Use file-type variables for keys and certificates
A file-type variable stores its value in a temporary file and puts the file’s path in the variable, which is what ssh -i, kubectl --kubeconfig and most TLS flags want. Generate a throwaway deploy key and upload the private half. The --data-urlencode "value@file" form sends the file contents with their newlines intact.
ssh-keygen -q -t ed25519 -N "" -C deploy@lab -f ~/deploy_key
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/variables" \
--data "key=DEPLOY_SSH_KEY" --data "variable_type=file" \
--data-urlencode "value@${HOME}/deploy_key" | jq -c '{key, variable_type}'
GitLab confirms the type. If it says env_var, the variable_type field was dropped and your job will receive the key text instead of a path.
{"key":"DEPLOY_SSH_KEY","variable_type":"file"}
Once the demo pipeline runs, its file-var job shows the variable is a path in ${CI_PROJECT_DIR}.tmp, a sibling of the build directory. On our Docker executor the file was world-readable, which comes from that executor’s umask 0000; other executors and the FF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR feature flag change it.
$ echo "DEPLOY_SSH_KEY=$DEPLOY_SSH_KEY"
DEPLOY_SSH_KEY=/builds/acme/ci-variables-demo.tmp/DEPLOY_SSH_KEY
$ ls -l "$DEPLOY_SSH_KEY"
-rw-rw-rw- 1 root root 399 Oct 18 16:37 /builds/acme/ci-variables-demo.tmp/DEPLOY_SSH_KEY
$ head -1 "$DEPLOY_SSH_KEY"
-----BEGIN OPENSSH PRIVATE KEY-----
Those rw-rw-rw- permissions matter because ssh rejects a private key that other users can read, so run chmod 600 "$DEPLOY_SSH_KEY" before handing it to ssh. The runner deletes the file in its cleanup_file_variables step at the end of the job, and you cannot declare a file-type variable in .gitlab-ci.yml at all. Settings, the API and the File type on the Run pipeline form are the ways in.
Create the demo pipeline
The pipeline below exercises every remaining level at once: global and job variables:, rules:variables, a dotenv report, rules: conditions and two kinds of inputs. If stages and jobs are new to you, our first GitLab CI/CD pipeline tutorial explains them. Clone the project first. On a fresh host, set a Git identity before anything else, because git commit stopped with Author identity unknown on our clean replay without one. The token in the URL authenticates the push, and SSH works just as well if you already have a key on your account.
git config --global user.name "Lab Admin"
git config --global user.email "[email protected]"
git clone "https://oauth2:${GL_TOKEN}@${SITE_DOMAIN}/acme/ci-variables-demo.git"
cd ci-variables-demo
mkdir -p templates
Create the template first, because the main file includes it and a push without it fails the pipeline.
vim templates/deploy-service.yml
Its spec:inputs header declares four typed inputs. service_name has no default, which makes it required, plus a regex that only allows lowercase names. The other three have defaults, and environment is limited to three options. The --- line separates the header from the jobs and is mandatory.
spec:
inputs:
service_name:
type: string
description: "Lowercase service name, used in job names"
regex: ^[a-z][a-z0-9-]{2,30}$
replicas:
type: number
default: 2
run_tests:
type: boolean
default: true
environment:
type: string
options: ["dev", "staging", "production"]
default: "dev"
---
test-$[[ inputs.service_name ]]:
stage: test
rules:
- if: '"$[[ inputs.run_tests ]]" == "true"'
script:
- echo "Testing $[[ inputs.service_name ]] before a $[[ inputs.environment ]] deploy"
deploy-$[[ inputs.service_name ]]:
stage: deploy
script:
- echo "Deploying $[[ inputs.service_name ]] x$[[ inputs.replicas ]] to $[[ inputs.environment ]]"
Now open the main file, which holds every job in the demo. You’ll paste it whole, since the jobs reference each other through needs:.
vim .gitlab-ci.yml
Paste the complete pipeline. It opens with its own spec:inputs header, which turns deploy_target and dry_run into fields on the Run pipeline form, then includes the template twice with different inputs.
spec:
inputs:
deploy_target:
type: string
description: "Where the deploy-app job ships"
options: ["staging", "production"]
default: "staging"
dry_run:
type: boolean
default: true
---
stages:
- info
- build
- test
- deploy
default:
image: alpine:3
include:
- local: templates/deploy-service.yml
inputs:
service_name: api-gateway
replicas: 3
environment: staging
- local: templates/deploy-service.yml
inputs:
service_name: frontend
run_tests: false
environment: production
variables:
APP_NAME: "my-web-app"
APP_VERSION: "2.1.0"
P_A: "yaml-global"
P_B: "yaml-global"
P_C: "yaml-global"
P_D: "yaml-global"
P_E: "yaml-global"
P_F: "yaml-global"
P_G: "yaml-global"
predefined-vars:
stage: info
script:
- echo "CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA"
- echo "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME"
- echo "CI_COMMIT_REF_PROTECTED=$CI_COMMIT_REF_PROTECTED"
- echo "CI_PIPELINE_SOURCE=$CI_PIPELINE_SOURCE"
- echo "CI_PROJECT_PATH=$CI_PROJECT_PATH"
- echo "CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE"
- echo "CI_RUNNER_DESCRIPTION=$CI_RUNNER_DESCRIPTION"
- echo "GITLAB_USER_LOGIN=$GITLAB_USER_LOGIN"
secrets:
stage: info
script:
- echo "API_TOKEN=$API_TOKEN"
- echo "SIGNING_KEY=$SIGNING_KEY"
- echo "PROD_PASSWORD=${PROD_PASSWORD:-<empty on this ref>}"
- echo "API_TOKEN length is ${#API_TOKEN}"
file-var:
stage: info
script:
- echo "DEPLOY_SSH_KEY=$DEPLOY_SSH_KEY"
- ls -l "$DEPLOY_SSH_KEY"
- head -1 "$DEPLOY_SSH_KEY"
build-meta:
stage: build
script:
- echo "BUILD_ID=build-$CI_PIPELINE_IID" >> build.env
- echo "P_A=dotenv" >> build.env
- echo "P_B=dotenv" >> build.env
- echo "P_C=dotenv" >> build.env
- echo "P_D=dotenv" >> build.env
- echo "P_E=dotenv" >> build.env
artifacts:
reports:
dotenv: build.env
precedence:
stage: test
needs: [build-meta]
variables:
P_A: "yaml-job"
P_B: "yaml-job"
P_C: "yaml-job"
P_D: "yaml-job"
P_E: "yaml-job"
P_F: "yaml-job"
P_H: "yaml-job"
rules:
- if: $CI_PIPELINE_SOURCE
variables:
P_H: "rules-variables"
script:
- echo "BUILD_ID=$BUILD_ID"
- for v in P_A P_B P_C P_D P_E P_F P_G P_H; do eval echo "$v=\$$v"; done
deploy-app:
stage: deploy
rules:
- if: '"$[[ inputs.deploy_target ]]" == "production" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
- if: '"$[[ inputs.deploy_target ]]" == "staging"'
script:
- echo "Shipping $APP_NAME $APP_VERSION to $[[ inputs.deploy_target ]] (dry_run=$[[ inputs.dry_run ]])"
feature-only:
stage: deploy
rules:
- if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH
script:
- echo "Review app for $CI_COMMIT_REF_SLUG"
Commit both files and push. You don’t need to trigger anything, because the push itself starts the first pipeline.
git add .gitlab-ci.yml templates/deploy-service.yml
git commit -m "Variables and inputs demo pipeline"
git push origin main
Open Build > Pipelines and the first run appears within a few seconds. On our lab it passed with nine jobs across four stages, in 242 seconds on the first run and 59 seconds on the clean replay.
Then push an empty commit to a branch that isn’t protected, so the protected-variable and rules: examples have a second pipeline to compare against. On a fresh project this becomes pipeline 2:
git checkout -b feature-x
git commit --allow-empty -m "Feature branch run"
git push -q origin feature-x
git checkout main
Back on main, the clone is ready for the API calls in the next sections, and the feature branch pipeline shows up in the list with feature-x as its ref.
Read the predefined variables
GitLab injects predefined variables into every job without any configuration, and the predefined-vars job prints the ones you reach for most. Two of them deserve a close read. CI_COMMIT_REF_PROTECTED tells you whether protected variables will be present at all, and CI_REGISTRY_IMAGE already carries the registry host and port, so image names never need hardcoding.
$ echo "CI_COMMIT_SHORT_SHA=$CI_COMMIT_SHORT_SHA"
CI_COMMIT_SHORT_SHA=fc161eb0
$ echo "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME"
CI_COMMIT_REF_NAME=main
$ echo "CI_COMMIT_REF_PROTECTED=$CI_COMMIT_REF_PROTECTED"
CI_COMMIT_REF_PROTECTED=true
$ echo "CI_PIPELINE_SOURCE=$CI_PIPELINE_SOURCE"
CI_PIPELINE_SOURCE=push
$ echo "CI_PROJECT_PATH=$CI_PROJECT_PATH"
CI_PROJECT_PATH=acme/ci-variables-demo
$ echo "CI_REGISTRY_IMAGE=$CI_REGISTRY_IMAGE"
CI_REGISTRY_IMAGE=gitlab.example.com:5050/acme/ci-variables-demo
$ echo "CI_RUNNER_DESCRIPTION=$CI_RUNNER_DESCRIPTION"
CI_RUNNER_DESCRIPTION=docker-runner
$ echo "GITLAB_USER_LOGIN=$GITLAB_USER_LOGIN"
GITLAB_USER_LOGIN=root
CI_REGISTRY_IMAGE is only set when the Container Registry is enabled on the instance, so an empty value there points at the server configuration, not at your YAML. CI_PIPELINE_SOURCE reads push here and api on the triggered run later, which is what the rules: examples key on.
Check the precedence order GitLab applies
Our precedence job prints all eight keys after GitLab has merged every level. The dotenv report from build-meta reaches it through needs:, so it competes like any other source. Run the pipeline once more through the API with a pipeline variable for P_A and a non-default input, which also gives the rules: section something to show.
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
--header "Content-Type: application/json" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/pipeline" \
--data '{"ref":"main","variables":[{"key":"P_A","value":"pipeline"}],"inputs":{"deploy_target":"production","dry_run":false}}' \
| jq -c '{id, status, source}'
GitLab answers with the new pipeline’s ID and an api source. A message field instead means the request was refused, which the next section reproduces on purpose.
{"id":3,"status":"created","source":"api"}
When that run finished, its precedence job printed this. The id in the API response is instance-wide, while build-N uses the per-project pipeline IID, so the two match only on a fresh instance like ours. Compare it with the push pipeline, where P_A read project because no pipeline variable was set.
BUILD_ID=build-3
P_A=pipeline
P_B=project
P_C=group
P_D=instance
P_E=dotenv
P_F=yaml-job
P_G=yaml-global
P_H=rules-variables
Read it as a ladder. Keys P_A to P_G were each defined at every level from global YAML up to the one they report, so the printed value is the highest level that set them. P_H is the exception: it only had a job value and a rules:variables value, so it proves that pair and nothing else.
| Rank | Source | Key that proved it | Value printed |
|---|---|---|---|
| 1 | Pipeline variable (API, Run pipeline form, schedule) | P_A | pipeline |
| 2 | Project variable | P_B | project |
| 3 | Group variable | P_C | group |
| 4 | Instance variable | P_D | instance |
| 5 | Dotenv report from a job in needs: | P_E (beat job and global YAML) | dotenv |
| 6 | rules:variables | P_H (beat the job’s own value; its place below dotenv is from the docs) | rules-variables |
| 7 | Job variables: | P_F | yaml-job |
| 8 | Global variables: | P_G | yaml-global |
Two rows surprise people. Dotenv sits below every settings level, so P_A to P_D all lost their dotenv value. A build job cannot override a project or instance variable by writing a dotenv file, which is a safety feature when the settings value is a credential and a trap when you expected the build output to win. And everything in .gitlab-ci.yml ranks under everything in settings, so a forgotten project variable silently overrides the YAML you’re staring at. The same order is in the GitLab CI/CD variables documentation, which also places manual job variables and policy variables above pipeline variables; we did not test those two.
Restrict who can set pipeline variables
Because a pipeline variable outranks everything else, anyone allowed to set one can override your project secrets for that run. GitLab gates it with Settings > CI/CD > Variables > Minimum role to use pipeline variables. Our new project on self-managed 19.5 came up set to Developer, although the docs give Maintainer as the self-managed default, and new projects in new namespaces on GitLab.com start at No one allowed, so a pipeline that works on your server can fail there:

Set it to No one allowed through the API, then try the same variable again and an inputs-only run after it. Read this before you run it: once the setting is No one allowed or Owner, only a project Owner can change it again, so a Maintainer who runs the first call cannot undo it. Our token belonged to the instance administrator, which is why our reset worked.
curl -s --request PUT --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}" \
--data "ci_pipeline_variables_minimum_override_role=no_one_allowed" | jq -r .ci_pipeline_variables_minimum_override_role
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
--header "Content-Type: application/json" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/pipeline" \
--data '{"ref":"main","variables":[{"key":"P_A","value":"pipeline"}]}'
echo
curl -s --request POST --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
--header "Content-Type: application/json" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/pipeline" \
--data '{"ref":"main","inputs":{"deploy_target":"staging"}}' | jq -c '{id, status}'
The variable is refused while the inputs-only run goes through, which is the whole argument for exposing parameters as inputs.
no_one_allowed
{"message":{"base":["Insufficient permissions to set pipeline variables"]}}
{"id":4,"status":"created"}
We’d leave production projects at No one allowed or Maintainer and give everyone else inputs. If you are an Owner, put the setting back to developer with the same PUT to keep following the examples.
Run jobs conditionally with rules
rules:if evaluates variables and interpolated inputs when the pipeline is created and decides whether a job exists in it at all. The demo file uses three patterns, and our three pipelines showed each one firing:
feature-onlyran onfeature-xonlytest-frontendwas never createddeploy-appran onstaging, waited onproduction
The branch rule compares $CI_COMMIT_BRANCH with $CI_DEFAULT_BRANCH, and the frontend tests disappeared because the template’s rule tests run_tests and the second include passed false. In the screenshot of an API-triggered pipeline from our first lab run (pipeline #2 there, #3 if you followed the steps above), GitLab marks it Blocked rather than Passed because deploy-app is waiting for someone to press play, which is what you want from a production deploy gate.

Inputs inside rules:if are interpolated as text before the expression is evaluated, so quote both sides as strings the way the file does with '"$[[ inputs.run_tests ]]" == "true"'. GitLab’s docs also note that job-only variables such as CI_JOB_ID and CI_JOB_TOKEN don’t exist yet at that point, so a rule should never test them.
Reuse a template with spec:inputs and include
Two include: entries turned one template into three jobs with different names and values. Each include passes its own inputs, the template’s defaults fill the gaps, and the job names are built from the input, so two includes never collide. The deploy jobs from the first pipeline show the values baked in.
$ echo "Deploying api-gateway x3 to staging"
Deploying api-gateway x3 to staging
Our frontend include never set replicas, so it fell back to the default of 2 without any warning. Check defaults whenever you reuse a template, because nothing in the job log tells you a value came from one.
$ echo "Deploying frontend x2 to production"
Deploying frontend x2 to production
Notice that the runner logs the command after interpolation. Inputs are not variables the shell expands. GitLab rewrote the YAML before the job was created, which is why the echoed command already contains api-gateway, and it’s also why inputs cannot carry secrets, since the value is written into the job definition for anyone with access to read.
Nothing in the template syntax used here changed between GitLab 18.10, which the earlier version of this guide used, and 19.5. The inputs documentation lists these additions since 18.10:
- array index access such as
$[[ inputs.arr[0] ]], 18.10 behind a flag, GA in 18.11 optionsfor array inputs, 19.0- the
splitinterpolation function, 19.2 - external input files from another project, 19.4, behind a flag that is off by default
Conditional spec:inputs:rules (18.7) and default: null for values the user must type (18.9) were already there before 18.10. We did not test any of these additions.
Add inputs to the Run pipeline form
A spec:inputs header at the top of .gitlab-ci.yml puts typed fields on Build > Pipelines > New pipeline, which a template header cannot do. deploy_target renders as a dropdown limited to its two options and dry_run as a true/false toggle, so whoever runs the pipeline by hand cannot type an invalid value.

Pipeline-level inputs must be declared in the main .gitlab-ci.yml, not in an included file, and the docs cap a pipeline at 20 of them. Every input needs a default if pushes should keep working, because a push cannot supply values, and an input without a default would stop every push pipeline with the “required value has not been provided” error shown in the next section.
Fix common input and variable errors
Input errors stop the pipeline before any job runs, and GitLab names the file and the input, so they are the easy ones once you know the wording. The lint endpoint returns the same messages without creating a pipeline, which makes it the fastest way to test a template change. Save a broken include to a scratch file in the repository.
vim bad-include.yml
This one leaves out the required service_name, the mistake you’d make when copying an include from another project.
include:
- local: templates/deploy-service.yml
inputs:
replicas: 3
Send it to the project’s lint endpoint, which resolves the local: include against the repository on the server, not your local copy.
jq -n --rawfile c bad-include.yml '{content: $c}' | curl -s --request POST \
--header "PRIVATE-TOKEN: ${GL_TOKEN}" --header "Content-Type: application/json" \
"https://${SITE_DOMAIN}/api/v4/projects/${PROJECT_ID}/ci/lint" --data @- | jq '{valid, errors}'
GitLab reports the missing input by name and marks the content invalid. Fix the include and run the same call until you see "valid": true.
{
"valid": false,
"errors": [
"`templates/deploy-service.yml`: `service_name` input: required value has not been provided"
]
}
We ran the same check with other broken values in the include and against the pipeline API. These are the exact messages GitLab 19.5 returned, and each one is a search string worth knowing.
| What we passed | Error returned |
|---|---|
No service_name (no default) | `service_name` input: required value has not been provided |
replicas: three to a number input | `replicas` input: provided value is not a number |
service_name: API_Gateway | `service_name` input: provided value does not match required RegEx pattern |
environment: qa | `environment` input: `qa` cannot be used because it is not in the list of allowed options |
"dry_run":"maybe" through the API | `dry_run` input: provided value is not a boolean |
"deploy_env":"staging" (typo in the name) | unknown input arguments: deploy_env |
Each message is prefixed with the file that declared the input, such as `templates/deploy-service.yml`: or `.gitlab-ci.yml`:, which tells you whether to fix the include or the caller.
Pipeline failed with “provided value is not a number”
Pushed to a branch, a bad input does not fail a job. It fails the pipeline with zero jobs. We pushed replicas: three on a bad-input branch and the pipeline went straight to failed with the reason config_error and the message `templates/deploy-service.yml`: `replicas` input: provided value is not a number. Run the lint call above before pushing a template change and you never see a red pipeline for it.
“Insufficient permissions to set pipeline variables”
Your role is below the project’s minimum role for pipeline variables, or set to No one allowed. Ask a project Owner to lower it, because a Maintainer can only change the setting while it is still Developer or Maintainer, or better, move the value into an input, which that setting doesn’t block.
Masked variable rejected with “is invalid”
This API reply on a masked variable means the value broke a masking rule, usually by being shorter than 8 characters, containing a space or newline, or, with expansion on, using a character outside _ : @ - + . ~ = /. The UI drawer spells out which rule failed, so paste the value there when the API message is not enough, or store it as a file-type variable.
Protected variable is empty in the job
Your branch or tag isn’t protected, so GitLab did not export the variable. Either protect the branch under Settings > Repository > Protected branches, or untick Protect variable if the value is safe for every branch. Printing CI_COMMIT_REF_PROTECTED in the job settles which case you’re in within one run.
Remove the test variables and token
The instance variables P_A to P_D are exported into every project on the server, so delete them when you finish, or run the whole exercise on a test instance. A 204 per key means it is gone:
for k in P_A P_B P_C P_D; do
curl -s -o /dev/null -w "${k} %{http_code}\n" --request DELETE --header "PRIVATE-TOKEN: ${GL_TOKEN}" \
"https://${SITE_DOMAIN}/api/v4/admin/ci/variables/${k}"
done
The clone URL also left your token in plain text in .git/config, and the deploy key pair is still in your home directory. Strip the token from the remote, delete the key files, and revoke the token itself under Personal access tokens once you are done:
git remote set-url origin "https://${SITE_DOMAIN}/acme/ci-variables-demo.git"
rm -f ~/deploy_key ~/deploy_key.pub
Afterwards git remote -v should print the URL without credentials. That leaves the project with its variables and the acme group with P_A to P_C. Keep the group if you plan to follow our Docker build and Container Registry guide, which reuses it, and delete it when you want everything else gone: on GitLab 18.0 and later that schedules the group for deletion, and an Owner can use Actions > Delete permanently to remove it now.