The problem
A pipeline needs values: a version number, a registry name, and passwords or tokens to push images and commit to repos. Put a password in the YAML and it is in git forever, readable by everyone with a clone. This lesson is about where each kind of value belongs and how the pipeline reads it.
What you need to know already: 25.4 (pipeline YAML, $(Name) variables, logs), 6.12 (bash parameter expansion), 22.14 (a service principal for CI), 22.21 (a pipeline identity with no secret: OIDC), 22.23 (Key Vault).
Words for this lesson
- variable (pipeline) - a named value the pipeline can use, like
VERSION. - secret variable - a variable whose value the server stores encrypted and hides from logs.
- masking - replacing a secret's value with
***wherever it shows up in a log. - service connection - a stored, named credential (registry, cluster, Azure subscription) that tasks use by name, without the pipeline ever seeing it.
- compile (a pipeline) - the step where Azure DevOps reads your YAML, fills in templates and works out the list of stages, jobs and steps, before any agent starts.
Three syntaxes, three moments
Azure Pipelines has three ways to write "the value of x", and each is filled in at a different moment:
${{ variables.x }} template expression expanded when the YAML is compiled,
before anything runs. Can shape the pipeline
(insert steps, pick templates). Knows only
parameters and variables defined in YAML.
$(x) macro replaced in a task's inputs just before the
task runs. Unknown name -> left as literal $(x)
$[ variables.x ] runtime expression evaluated at runtime; used for conditions and
for variables computed from other jobs' outputs
- template expression
${{ }}- worked out while compiling. Use it when the value decides the shape of the pipeline (lesson 25.9). - macro
$( )- text replacement just before one step runs. The common one. - runtime expression
$[ ]- worked out while the run is going, when a value only exists after an earlier job produced it.
The macro rule "unknown stays literal" has a nasty consequence in bash:
- script: echo "deploying $(ImageTag)" # typo: the variable is imageTag ... no:
Variable names are case-insensitive in Azure Pipelines, so $(imagetag) works - but a real typo like $(ImageTga) stays in the script text, and bash treats $(ImageTga) as command substitution (Ch 6: "run the command ImageTga and paste its output"):
2026-09-24T10:00:41.9786725Z bash: line 1: ImageTga: command not found
2026-09-24T10:00:41.9786725Z deploying
The step does not even fail (echo is the last command, lesson 25.4's trap).
Where variables come from, and who wins
variables: # pipeline level
VERSION: 1.4.0
- group: payments-common # a variable GROUP from the Library (list form only)
stages:
- stage: Deploy
variables: # stage level overrides pipeline level
ENV: dev
jobs:
- job: deploy
variables: # job level overrides stage level
ENV: dev-eu
The most specific scope (where it is defined) wins: job > stage > pipeline. Two more sources override YAML for the rest of the job: variables set when someone starts a run by hand ("queue time", if the variable allows it), and variables a script sets with ##vso[task.setvariable] (see "Logging commands" below).
(That first block is deliberately wrong: you cannot mix the map form (KEY: value) and the list form (- name: / - group:) at one level. As soon as you need a group, use the list form everywhere:)
variables:
- name: VERSION
value: 1.4.0
- group: payments-common
Variable groups
A variable group (in the web UI under Pipelines > Library) holds values shared by many pipelines: registry names, the URL of a code-quality server, a team's chat webhook. A group can be linked to an Azure Key Vault (22.23): the group then shows the vault's secrets, fetched at run time with the permissions of a service connection - the secret never lives in Azure DevOps at all. That is the typical bank setup: secrets in Key Vault, pipelines reading them through a linked group or the AzureKeyVault@2 task.
ci groups (simulator) lists the groups: their variables, and whether they are linked to a vault:
$ ci groups
GROUP VARIABLES LINKED
payments-common REGISTRY, SONAR_URL, TEAMS_HOOK -
payments-prod-kv payments-db-password Key Vault kv-payments-prod
Secret variables
Marking a variable secret does three things, and not a fourth:
- The value is stored encrypted and never shown again in the UI.
- The agent masks it: any log line containing the exact value shows
***. - It is not available to scripts as an environment variable. You must map it explicitly with
env:. - It does not stop a script from leaking it.
env: on a step sets environment variables for that one step only. Here it hands the secret to the script as $CONFIG_TOKEN:
- bash: |
set -euo pipefail
git clone -q "https://ci-bot:[email protected]/pay/payments-config.git" cfg
env:
CONFIG_TOKEN: $(CONFIG_TOKEN) # without this line, $CONFIG_TOKEN is EMPTY
(https://USER:TOKEN@host/... is how you give git a username and token in the URL itself; -q = quiet.) Without the mapping, the clone runs with an empty password and the server says so:
remote: HTTP Basic: Access denied. If a password was provided for Git authentication, the password was incorrect or you're required to use a token instead of a password. ...
fatal: Authentication failed for 'https://git.lab/pay/payments-config.git/'
Masking only matches the exact string. These all leak it:
echo "$CONFIG_TOKEN" | base64 # a different string: printed in full
echo "${CONFIG_TOKEN:0:6}" # a substring (first 6 chars): printed
echo "$CONFIG_TOKEN" | rev # reversed: printed
set -x # xtrace prints commands AFTER expansion ...
# ... masked, but only if it is the exact value
(base64 re-encodes text as other letters, Ch 15.33.) And a secret written to a file that you then publish as an artifact is not masked at all. Treat masking as a safety net for accidents, not as access control.
What never goes in a variable
- In the YAML file: no secret, ever. The file is in git: every clone, fork and CI log of the repo has it, and
git revertdoes not remove it from history. A secret that was committed is burned: rotate it (revoke the old value and issue a new one). - Plain (non-secret) variables for anything sensitive: they are shown in the UI, in
ci vars, and exported to every script's environment (env | sortprints them). - Long-lived cloud credentials when you can avoid them: use a service connection with workload identity federation (OIDC, 22.21). Azure DevOps presents a short-lived token that Entra ID trusts; there is no client secret to store, leak or rotate.
Output variables: passing values between jobs and stages
A job's variables disappear when it ends. To hand a value (say, the image digest) to a later job, a step publishes it as an output variable:
- job: build
steps:
- bash: echo "##vso[task.setvariable variable=digest;isOutput=true]sha256:3f0d..."
name: meta # the step NAME is part of the address
- job: deploy_dev
dependsOn: build
variables:
digest: $[ dependencies.build.outputs['meta.digest'] ] # same stage
- stage: Prod
jobs:
- job: deploy
variables:
digest: $[ stageDependencies.Build.build.outputs['meta.digest'] ] # other stage
The address reads: from the job build, the output digest of the step named meta. dependencies. works inside the same stage, stageDependencies.STAGE. across stages. Note the $[ ] runtime syntax: the value only exists once build ran.
Three details make or break it: the step must have a name:, isOutput=true must be set, and the consuming job must depend on the producer (directly or through its stage). A missing piece gives you an empty string, not an error.
Service connections
A service connection is a named, permissioned credential the pipeline can use without seeing it: a Docker registry, a Kubernetes cluster, an Azure subscription. Tasks take its name:
- task: Docker@2
inputs:
containerRegistry: registry-lab # the service connection
repository: pay/payments-api
command: buildAndPush
tags: $(VERSION)
- task: Kubernetes@1
inputs:
connectionType: Kubernetes Service Connection
kubernetesServiceEndpoint: lab-cluster
command: login # sets KUBECONFIG for the rest of the job
The connection can be restricted to specific pipelines and protected by the same approvals as an environment (lesson 25.11). The agent user (azagent) has none of your credentials; after Kubernetes@1 login the job has a temporary kubeconfig (Ch 15.1) in _work/_temp, and kubectl/helm in later steps use it.
Logging commands you will use
A logging command is a specially formatted line a script prints to its output; the agent reads it and acts on it instead of just logging it:
##vso[task.setvariable variable=NAME]value set for later steps of this job
##vso[task.setvariable variable=NAME;isOutput=true]value ... and as an output
##vso[task.setvariable variable=NAME;issecret=true]value ... and mask it from now on
##vso[task.logissue type=error]message an error annotation
##vso[task.complete result=SucceededWithIssues;] mark the step
##vso[build.updatebuildnumber]1.5.0-rc.2 rename the run
A script that fetches a token at runtime should set it with issecret=true immediately - before anything can print it.
What you can now do
- Choose between
${{ }},$( )and$[ ]by when the value is known. - Store a token as a secret variable and hand it to one step with
env:. - Pass a value from one job to another with an output variable.