The problem
Lesson 25.1 said "build once, deploy many". This lesson builds a pipeline that does it: one image, recorded by digest, deployed to dev automatically and to prod after a human approves. First, the pipeline most teams start with, and why it is wrong.
What you need to know already: 25.1 (build once, digests), 25.4 (stages, jobs, tasks), 25.7 (output variables, service connections), 10.5 (tags and digests).
Words for this lesson
- promotion - moving an already-built artifact to the next environment, unchanged.
- environment (Azure Pipelines) - a named target like
payments-prodthat the server tracks: what was deployed there, and which checks must pass first. - approval - a check where a named person must click Approve before the stage may start.
- deployment job - a job that deploys to an environment (
deployment:instead ofjob:).
The wrong pipeline, first
- stage: Dev
jobs:
- job: deploy
steps:
- task: Docker@2 # builds an image FOR dev
inputs: { command: buildAndPush, repository: pay/payments-api, tags: dev }
- stage: Prod
jobs:
- job: deploy
steps:
- script: ./mvnw -B package -DskipTests # rebuilds the jar
- task: Docker@2 # builds ANOTHER image for prod
inputs: { command: buildAndPush, repository: pay/payments-api, tags: prod }
Everything tested in dev is thrown away at the Prod stage. The prod image is built from the same commit but not the same bytes: FROM eclipse-temurin:21-jre may resolve to a newer base image, Maven may resolve a newer transitive dependency (a library your library needs), and -DskipTests means the prod jar was never tested at all. When prod breaks and dev did not, nobody can tell whether it is the environment or the artifact.
The right shape
Build stage: test -> build image ONCE -> push -> record the DIGEST
Dev stage: deploy <digest> with values-dev (automatic)
Prod stage: deploy <digest> with values-prod (after an approval)
- stage: Build
jobs:
- job: build
steps:
- script: ./mvnw -B verify
- task: Docker@2
inputs:
containerRegistry: registry-lab
repository: pay/payments-api
command: buildAndPush
tags: |
$(VERSION)
$(Build.BuildId)
- bash: |
set -euo pipefail
digest=$(docker inspect --format '{{index .RepoDigests 0}}' $(IMAGE):$(VERSION) | cut -d@ -f2)
echo "##vso[task.setvariable variable=digest;isOutput=true]$digest"
name: image
- stage: Prod
jobs:
- deployment: deploy
environment: payments-prod # the approval lives on the environment
variables:
digest: $[ stageDependencies.Build.build.outputs['image.digest'] ]
strategy:
runOnce:
deploy:
steps:
- bash: helm upgrade --install payments-api ... --set image.digest=$(digest) --wait
Reading it: the Build stage pushes two tags, then the step named image asks Docker for the digest (docker inspect --format '{{index .RepoDigests 0}}' prints the first name@sha256:... the registry gave back; cut -d@ -f2 keeps the part after @) and publishes it as the output variable digest. The Prod stage reads it with stageDependencies (25.7) and passes it to helm upgrade --install - Helm, lesson 25.19, installs or updates the app's Kubernetes objects; --set image.digest= overrides one setting and --wait waits for ready pods.
docker inspect's RepoDigests gives registry.lab/pay/payments-api@sha256:... after a push. The digest travels as an output variable (or as a small artifact file like release.env, which also documents what was built). The Helm chart then renders image: registry.lab/pay/payments-api@sha256:... - a reference that cannot silently change.
Tags that help, tags that hurt
1.5.0 semantic version, ONE build per version; registry refuses overwrites
9c41e7a / 1041 commit or build id: unique, traceable back to the source
latest, main, MUTABLE (they move to a new image every build). Fine to find "the newest", never to deploy
dev, prod, 1.5
registry.lab (like Azure Container Registry, ACR, with tag immutability enabled) refuses to overwrite a MAJOR.MINOR.PATCH tag:
unknown: The image tag '1.5.0' already exists in the 'pay/payments-api' repository and cannot be overwritten because the repository is immutable.
That error is a feature: it forces a version bump instead of a silent swap.
Deployment jobs and environments
- deployment: deploy # instead of `job:`
environment: payments-prod # created on first use; history of what ran where
strategy:
runOnce: # deploy once; other strategies exist for VMs
deploy:
steps: [...]
A deployment job:
- records a deployment in the environment's history (which run, which commit);
- does not check out the repo automatically (add
- checkout: selfif needed); - does download the run's pipeline artifacts automatically in the
deployhook; - runs checks of the environment before starting: approvals, business hours, "branch must be main", an Azure Monitor query (Ch 24), an exclusive lock so two runs cannot deploy to prod at once.
strategy: runOnce: deploy: steps: is the fixed nesting a deployment job needs: run the deploy steps once. In the output below, ci show shows the run parked at the gate, ci approve RUN approves it, and ci env NAME shows the environment's checks and deployment history.
# a run waiting at the Prod gate, as in the next mission
ci show
...
⏸ Stage Prod Waiting for approval: environment 'payments-prod' (ci approve 1041)
· deploy -> environment payments-prod pending
ci approve 1041
Approved: stage 'Prod' of run #1041 (environment payments-prod)
ci env payments-prod
Environment payments-prod
Checks: Approvals (Learner)
Deployments:
run #1041 20260924.3 9c41e7a 2026-09-24T10:14:02.000Z
The approval is attached to the environment, not to the YAML: whoever edits the pipeline cannot remove it. That is the property auditors care about.
How to promote without rebuilding, in one sentence
Build and push once, capture the digest, and have every later stage reference that digest, with only the values file differing per environment.
What you can now do
- Explain what goes wrong when a pipeline rebuilds per environment.
- Capture a pushed digest as an output variable and deploy it in later stages.
- Put an approval on an environment, and say why it lives there and not in the YAML.