Skip to content

Promote to staging

  • Lesson 9
  • 50 min
  • Module 3: Manage environments and secrets
Lesson 9 of 15 · CI/CD, Testing and Safe ReleasesPromote to staging

What you will be able to do

Move the tested image to staging with no person typing a deploy command. Check it there, and let it reach production only when a reviewer says yes.

Before you startWhat to know and to have readyAbout 50 minutes

What you need to know

What you need on your computer

Run with GitHub Actions (ubuntu-24.04 runners) in the public repository Nextia-Intelligence/ticket-api-ci, Docker Desktop 4.81.0 (Engine 29.6.1, Compose 5.2.0) on macOS (Apple silicon), and Azure CLI 2.88.0 in canadacentral. The Windows and Linux steps are not tested.

Amira’s rule for this lesson: after CI, nobody builds, and nobody types a deploy command. The pipeline takes the digest from CI’s evidence and moves it, one environment at a time. A promotion is this move of the same artifact to the next environment.

CI on main succeeds. Find the image takes the digest from build-info.json. Staging: sign in with OIDC, az containerapp update --image, wait for the revision, smoke check. If a step fails, the job fails and production never starts. If all pass, the question is: does the reviewer approve? If the reviewer rejects, production stays on the old digest. If the reviewer approves, production gets the same digest with the same steps. At the end, both environments run one digest.
The Deploy workflow. Every failure stops the digest where it is, and production keeps running its old version.
Text description of the diagram

The workflow starts only after CI succeeds on main. Its first job reads the digest from CI’s build-info.json. Staging deploys it, waits until the new revision is ready, and runs the smoke check. A failure at any step ends the run: production never starts. If staging passes, the production job waits for a reviewer. An approval sends the same digest through the same steps. A rejection leaves production as it was.

Three kinds of check happen in staging:

Check What it proves here Who does it
Health check The new revision starts and answers: /health and /ready give 200 The platform (the probes) and the smoke check
Integration check This image works with this environment’s settings and secret: classify with the key gives 200, without the key 401 The smoke check
User-acceptance check The change does what users need, judged by a person A tester or the help desk lead, in staging, before the approval

The smoke check, from Define checks: Eight checks, eight purposes, sends a few fast requests on the most important paths to a real deployment. It does not replace the tests in CI; it proves that this deployment works. For the user-acceptance check, staging has SHOW_DOCS=true: a tester opens /docs and tries real tickets in the browser. In the test run, the reviewer approved on the smoke results only, because one person did everything.

The workflow has two files. deploy.yml decides what to deploy and in which order. deploy-to.yml holds the steps for one environment, so that staging and production run exactly the same steps.

.github/workflows/deploy.yml
# Continuous delivery: promote one image, by digest, to staging and then to
# production. It starts when CI succeeds on main, or by hand with a digest
# (for a hotfix or a rollback). Production waits for a reviewer's approval.
name: Deploy
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
digest:
description: "The image digest to deploy, for example sha256:4f1c..."
required: true
reason:
description: "Why you deploy it by hand (hotfix, rollback, ...)"
required: true
permissions:
contents: read
concurrency:
group: deploy
cancel-in-progress: false
jobs:
resolve:
name: Find the image
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04
permissions:
actions: read
outputs:
digest: ${{ steps.digest.outputs.digest }}
steps:
- name: Get build-info.json from the CI run
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v7
with:
name: build-info
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Choose the digest
id: digest
env:
INPUT_DIGEST: ${{ inputs.digest }}
run: |
if [ -n "$INPUT_DIGEST" ]; then digest="$INPUT_DIGEST"; else digest=$(jq -r .digest build-info.json); fi
case "$digest" in sha256:*) ;; *) echo "Not a digest: $digest"; exit 1;; esac
echo "digest=$digest" >> "$GITHUB_OUTPUT"
echo "Deploying ghcr.io/${{ github.repository }}@$digest" >> "$GITHUB_STEP_SUMMARY"
staging:
needs: resolve
uses: ./.github/workflows/deploy-to.yml
with:
environment: staging
image: ghcr.io/${{ github.repository }}@${{ needs.resolve.outputs.digest }}
secrets: inherit
permissions:
contents: read
id-token: write
production:
needs: [resolve, staging]
uses: ./.github/workflows/deploy-to.yml
with:
environment: production
image: ghcr.io/${{ github.repository }}@${{ needs.resolve.outputs.digest }}
secrets: inherit
permissions:
contents: read
id-token: write

The full file is in the reference repository: deploy.yml at 92fff66. The parts, in order:

Part What it does, and why
workflow_run of CI, completed, branches: [main] Starts when a CI run on main ends. Pull requests and other branches never deploy.
if: … conclusion == 'success' A red CI run ends the Deploy run at once: GitHub skips the resolve job and the jobs after it.
workflow_dispatch with digest and reason A deployment by hand, of a digest that CI built earlier. Module 5 uses it for the hotfix and the rollback.
concurrency: deploy, cancel-in-progress: false Only one deployment at a time, and a running one is never stopped halfway. A second one waits.
download-artifact with run-id Takes build-info.json from the CI run that started this run. actions: read lets the token read that run’s artifacts.
case "$digest" in sha256:* Refuses anything that is not a digest, such as a tag. A tag can move; a digest cannot.
uses: ./.github/workflows/deploy-to.yml Calls the reusable workflow for one environment. needs: [resolve, staging] makes production wait for staging.
secrets: inherit Passes the secrets on. The called job still gets an environment’s secrets only after its rules pass.

One more fact about workflow_run: GitHub runs the Deploy workflow file from the default branch, main, even when the CI run was for another branch. So the deployment steps are always the reviewed ones on main. Module 5 needs this for the hotfix.

.github/workflows/deploy-to.yml
# The same deployment steps for every environment. The environment's settings
# (app name, resource group, URL, identity) are variables of the GitHub
# environment; its API key for the smoke check is an environment secret.
name: Deploy to one environment
on:
workflow_call:
inputs:
environment:
required: true
type: string
image:
required: true
type: string
jobs:
deploy:
name: Deploy to ${{ inputs.environment }}
runs-on: ubuntu-24.04
timeout-minutes: 20
environment:
name: ${{ inputs.environment }}
url: ${{ vars.APP_URL }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- name: Sign in to Azure with this environment's identity (no password)
uses: azure/login@v3
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- name: Deploy the image
env:
IMAGE: ${{ inputs.image }}
run: |
az config set extension.use_dynamic_install=yes_without_prompt --only-show-errors
az containerapp update --name "${{ vars.APP_NAME }}" --resource-group "${{ vars.RESOURCE_GROUP }}" \
--image "$IMAGE" --query "properties.latestRevisionName" -o tsv
- name: Wait for the new revision
run: |
for _ in $(seq 1 30); do
state=$(az containerapp show -n "${{ vars.APP_NAME }}" -g "${{ vars.RESOURCE_GROUP }}" \
--query "properties.latestReadyRevisionName == properties.latestRevisionName" -o tsv)
[ "$state" = "true" ] && echo "The new revision is ready." && exit 0
sleep 10
done
echo "The new revision did not become ready in 5 minutes."; exit 1
- name: Smoke check
env:
SMOKE_API_KEY: ${{ secrets.SMOKE_API_KEY }}
run: scripts/smoke.sh "${{ vars.APP_URL }}" "$SMOKE_API_KEY"
- name: Record the deployment
run: |
{
echo "### ${{ inputs.environment }}"
echo "Image: \`${{ inputs.image }}\`"
echo "Revision: $(az containerapp show -n "${{ vars.APP_NAME }}" -g "${{ vars.RESOURCE_GROUP }}" --query properties.latestRevisionName -o tsv)"
echo "Started by: ${{ github.actor }}, run ${{ github.run_id }}"
} >> "$GITHUB_STEP_SUMMARY"

deploy-to.yml at 92fff66. environment: name is the line that connects this job to everything from the last lesson. It brings the variables, the secret, the branch policy, the reviewer and the OIDC subject. The same file serves both environments, and only vars and secrets change. The four steps:

  1. Sign in with the environment’s identity, as in Credentials and access.
  2. Deploy with az containerapp update --image. It makes a new revision and changes nothing else on the app: SHOW_DOCS and the secret stay as they are.
  3. Wait until the newest revision is also the newest ready revision: 30 tries, 10 s apart, at most 5 minutes. Until then, the old revision keeps the traffic, as in the Docker course.
  4. Smoke check the environment’s own address with its own key, then write a summary.

Worked example (live): the first deployment

Section titled “Worked example (live): the first deployment”

Amira committed the two workflow files with the local environment files on feature/deploy, and opened the pull request feature/deploy into main: pull request #1. Both required checks passed, and she merged it (merge commit 43de57b). CI ran on main, and its success started Deploy run 37664760027.

The first attempt failed at the sign-in, as Credentials and access shows. After the fix, attempt 2 ran. The timeline (live, from the run’s jobs and log):

Time (UTC) What happened
18:10:23 “Find the image” took the digest sha256:a8af875a… from the CI run’s build-info.json (kept from attempt 1).
18:11:32 The staging job started. 18:11:57: Azure CLI login succeeds by using OIDC.
18:12:18 az containerapp update printed the new revision ticket-api-staging--0000001.
18:12:42 The new revision is ready., then the four smoke checks passed. The job took 1 min 15 s.
18:12:47 The production job waited for its reviewer.
18:13:04 Approved. The production job started with the same digest.
18:13:29 New revision ticket-api-production--0000001. 18:13:54: ready, and the four checks passed. The job took 55 s.

The staging log (live, shortened):

Attempting Azure CLI login by using OIDC...
Azure CLI login succeeds by using OIDC.
ticket-api-staging--0000001
The new revision is ready.
ok health (200)
ok ready (200)
ok classify with the key (200)
ok classify without a key (401)
The public page of Deploy run 1 (37664760027), logged out. Re-run triggered by usmanahmed99 on commit 43de57b; Status: Success; Total duration: 2m 34s. The graph of deploy.yml, on: workflow_run, shows three green jobs in a row: Find the image (4s), staging / Deploy to staging (1m 15s) with the staging address under it, and production / Deploy to production (55s) with the production address under it. Below, Deployment protection rules: usmanahmed99 approved production with the comment: Staging smoke checks passed for this digest. Approved for production (course test run).
The first deployment after the fix (live, captured logged out). Staging, then production, and the approval with its comment is part of the run's public record.

The page shows the order (staging before production), the addresses from environment: url, and the approval: who, for which environment, and why. Anyone can open it. That is a traceable approval.

When the production job waits, the run’s page shows a Review deployments button for the reviewers. The steps:

  1. Open the run (Actions → Deploy → the run).
  2. Check the staging job: the smoke check lines, and the image digest in the summary. If a tester checked staging, read their result.
  3. Choose Review deployments.
  4. Select production, and write a comment that says what you checked.
  5. Choose Approve and deploy. To stop the release, choose Reject.

The test run approved through GitHub’s REST API, which does the same thing and records the same comment. To approve that way, you need the environment’s ID and the run’s ID. Do this only on your own repository, because it starts a production deployment:

Terminal (optional, with gh)
gh api repos/<you>/ticket-api-ci/environments --jq '.environments[] | {name, id}'
gh api --method POST repos/<you>/ticket-api-ci/actions/runs/<run-id>/pending_deployments \
-F "environment_ids[]=<production id>" -f state=approved \
-f comment="Staging smoke checks passed for this digest."

The record of the approval, read back from the run (live):

Terminal
gh api repos/Nextia-Intelligence/ticket-api-ci/actions/runs/37664760027/approvals --jq '.[] | {state, comment, user: .user.login, environments: [.environments[].name]}'
{"comment":"Staging smoke checks passed for this digest. Approved for production (C19 test run).","environments":["production"],"state":"approved","user":"usmanahmed99"}

Each job with environment: also makes a deployment record. The repository listed 21 of them at the end of the day, for example (live):

Terminal
gh api repos/Nextia-Intelligence/ticket-api-ci/deployments --jq '.[] | [.environment, .sha[0:7], .created_at] | @tsv'
production 92fff66 2026-10-07T18:18:57Z
staging 92fff66 2026-10-07T18:18:02Z
production 43de57b 2026-10-07T18:12:47Z
staging 43de57b 2026-10-07T18:11:28Z
staging 43de57b 2026-10-07T18:10:28Z

(The newest ones are left out.) The first line for 43de57b in staging is attempt 1, which failed. The deployments page of a repository needs a sign-in, so this course shows the API answer instead of a screenshot.

The image in production has a digest, but people talk about versions. The help desk lead asks “are we on 1.1.0?”, not “are we on sha256:4eb3db8d?”. A Git tag v1.1.0 names the commit. The image needs the version too. It must be the same image, not a new build. Configuration separation: A second build for the release shows why.

Amira’s second pull request, feature/release-tags into main (pull request #2), removed the tag trigger from ci.yml, added the release notes docs/releases/1.1.0.md, and added this workflow:

.github/workflows/release.yml
# A release: when a version tag (v1.2.0) is pushed, give the image that CI
# already built and tested for that commit its version tag, and create the
# GitHub release. Nothing is built again, so the release is the same digest
# that staging and production run.
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v7
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag the tested image with the version
id: tag
run: |
version="${GITHUB_REF_NAME#v}"
source="$IMAGE:sha-${GITHUB_SHA::7}"
docker buildx imagetools inspect "$source" > /dev/null || {
echo "No image $source: CI must build and test this commit first."; exit 1; }
docker buildx imagetools create --tag "$IMAGE:$version" "$source"
digest=$(docker buildx imagetools inspect "$IMAGE:$version" --format '{{json .Manifest}}' | jq -r .digest)
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
notes="docs/releases/${{ steps.tag.outputs.version }}.md"
[ -f "$notes" ] || { echo "Write the release notes in $notes first."; exit 1; }
{
cat "$notes"
echo
echo "Image: \`$IMAGE@${{ steps.tag.outputs.digest }}\` (tag \`${{ steps.tag.outputs.version }}\`)"
} > notes.md
gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md

release.yml at 92fff66. docker buildx imagetools create writes a new tag in the registry that points to the existing image. It copies no layers and builds nothing. If CI never built the commit, the release stops with No image …: CI must build and test this commit first. If the release notes are missing, it stops too.

The merge of pull request #2 made commit 92fff66. CI built it (sha-92fff66), and Deploy run 37665743749 promoted it: staging in 51 s, then the approval (“Release 1.1.0: staging smoke checks passed. Approved for production.”), then production in 1 min 8 s. Then Amira tagged the commit. Wait for the CI run on main to be green first, so that the image exists:

Terminal (on main)
git switch main
git pull
git tag -a v1.1.0 -m "ticket-api 1.1.0"
git push origin v1.1.0

The tag started Release run 37665781129. It took 8 s. Its log (live, shortened):

Login Succeeded!
#1 [internal] pushing ghcr.io/usmanahmed99/ticket-api-ci
#1 0.000 copying sha256:73311f52b091f4e64e4990f351159f0af4e0735056173260377c21829869dd1b from ghcr.io/usmanahmed99/ticket-api-ci:sha-92fff66 to ghcr.io/usmanahmed99/ticket-api-ci
#1 0.000 copying sha256:34c3ddd916f50832e73a8a30877e3ae872a2c7e2510b352f585f2bb049c86f49 from ghcr.io/usmanahmed99/ticket-api-ci:sha-92fff66 to ghcr.io/usmanahmed99/ticket-api-ci
#1 0.359 pushing sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a to ghcr.io/usmanahmed99/ticket-api-ci:1.1.0
https://github.com/Nextia-Intelligence/ticket-api-ci/releases/tag/v1.1.0

“copying” here means pointers: the two manifests (the linux/amd64 image and its attestation) get a second name. Check it yourself; anyone can read the public package:

Terminal
docker buildx imagetools inspect ghcr.io/usmanahmed99/ticket-api-ci:sha-92fff66
docker buildx imagetools inspect ghcr.io/usmanahmed99/ticket-api-ci:1.1.0

The output (both shortened to the first lines):

Name: ghcr.io/usmanahmed99/ticket-api-ci:sha-92fff66
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a
Name: ghcr.io/usmanahmed99/ticket-api-ci:1.1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a

One digest, three names: sha-92fff66, 1.1.0, and the digest itself, which staging and production already ran.

The public release page v1.1.0 of Nextia-Intelligence/ticket-api-ci, logged out. github-actions released it; tag v1.1.0, commit 92fff66 with a green check. The notes: ticket-api 1.1.0. The first release through the pipeline. The code is the same as at the end of the Docker course. Changes for clients: none. Checks: lint, 25 tests, pip-audit, image smoke check, staging smoke check. Owner: Amira. Watch after release: the production smoke check and the logs, for one hour. Rollback: deploy the previous digest with the Deploy workflow (Run workflow). Image: ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a (tag 1.1.0).
The GitHub release that release.yml made (live, captured logged out). The image line names the digest, so the release says exactly what runs in production.

The simulated route does the same promotion with two scripts. promote-local.sh starts one environment with one image, then runs the same smoke.sh as the pipeline:

scripts/promote-local.sh
#!/usr/bin/env bash
# Deploy one image to a local environment, then smoke-check it.
# scripts/promote-local.sh staging ghcr.io/<you>/ticket-api-ci@sha256:...
# scripts/promote-local.sh production ghcr.io/<you>/ticket-api-ci@sha256:...
# Needs .env (POSTGRES_PASSWORD) and secrets/<environment>_api_key.txt.
set -euo pipefail
environment=${1:?Give the environment: staging or production}
export IMAGE=${2:?Give the image with its digest}
settings="deploy/local/$environment.env"
[ -f "$settings" ] || { echo "No settings file $settings"; exit 1; }
docker compose -f deploy/local/compose.yaml --env-file "$settings" --env-file .env up -d --wait
port=$(grep '^PORT=' "$settings" | cut -d= -f2)
scripts/smoke.sh "http://127.0.0.1:$port" "$(cat "secrets/${environment}_api_key.txt")"
echo "$environment now runs $IMAGE"
scripts/stop-local.sh
#!/usr/bin/env bash
# Stop a local environment. Add --volumes to delete its database too.
# scripts/stop-local.sh staging [--volumes]
set -euo pipefail
environment=${1:?Give the environment: staging or production}
shift
# compose.yaml needs IMAGE to read the file, but "down" does not use it.
IMAGE=unused docker compose -f deploy/local/compose.yaml \
--env-file "deploy/local/$environment.env" --env-file .env down "$@"

up -d --wait returns only when every container is healthy, so the smoke check never runs against a half-started API. set -euo pipefail stops the script at the first failure. Make both scripts executable: chmod +x scripts/promote-local.sh scripts/stop-local.sh. Git records this, so the change is part of your commit.

The order is the pipeline’s order. You run staging first. You run production only after staging’s smoke check passed: that second command is your approval.

Amira ran the local staging with the image of release 1.1.0, on her Mac with Apple silicon:

Terminal (simulated route)
scripts/promote-local.sh staging ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a

Common mistake

no matching manifest for linux/arm64/v8

Symptom: the script stopped before any container started (simulated):

Image ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a Pulling
Image ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a Error no matching manifest for linux/arm64/v8 in the manifest list entries: no match for platform in manifest: not found
Error response from daemon: no matching manifest for linux/arm64/v8 in the manifest list entries: no match for platform in manifest: not found

Cause: CI builds only linux/amd64, the CPU type of the cloud. Docker asks for the image of its own computer, linux/arm64/v8 on Apple silicon, and the image has none. imagetools inspect above shows it: one linux/amd64 manifest and the attestation (unknown/unknown). On an Intel or AMD computer, this error does not happen.

Fix: do not build a second, arm64 image for your computer: that is a different artifact again. Ask for the cloud’s image, and let Docker Desktop emulate it. Add one line (and its comment) to the api service in deploy/local/compose.yaml:

deploy/local/compose.yaml (part)
api:
image: ${IMAGE:?Set IMAGE to the image name with its digest}
# The pipeline builds for the cloud's processors. On Apple silicon (arm64),
# Docker Desktop emulates them, so you run the same build as the cloud.
platform: linux/amd64

The reference repository added this line later, in pull request #6, after version 1.2.0. You add it now. Emulation is slower, but the test API started and passed its smoke check in about 10 s.

For a real AI service: a model server often uses CPU-specific code (for example, faster math libraries). An arm64 build for your laptop can give other numbers or other errors than the amd64 build in production. Test the build that production runs, even if it is slower on your computer.

Worked example (simulated): staging, then production

Section titled “Worked example (simulated): staging, then production”

With the platform line, the same command (simulated):

Image ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a Pulled
Network ticket-api-staging_default Created
Volume ticket-api-staging_pgdata Created
Container ticket-api-staging-db-1 Created
Container ticket-api-staging-api-1 Created
Container ticket-api-staging-db-1 Healthy
Container ticket-api-staging-api-1 Healthy
ok health (200)
ok ready (200)
ok classify with the key (200)
ok classify without a key (401)
staging now runs ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a

(The Creating, Starting and Waiting lines are left out.) The pull and the start took 10 s in total. Inside the container, Python reports the emulated CPU: docker exec ticket-api-staging-api-1 python -c "import platform; print(platform.machine())" printed x86_64.

Staging passed, so Amira approves her own local production: scripts/promote-local.sh production with the same image. The output has the same lines with ticket-api-production, and ends with production now runs ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d…. The image was already on the computer, so it needed no pull.

Now the comparison, with the same image in both:

Terminal (simulated route)
docker inspect --format '{{.Name}} {{.Image}}' ticket-api-staging-api-1 ticket-api-production-api-1
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8081/docs
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8082/docs

The output (simulated):

/ticket-api-staging-api-1 sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a
/ticket-api-production-api-1 sha256:4eb3db8d4ea249fb9b44b27d96deb68aba6da3e3fbc9cf32311a9cbadf4d857a
200
404

The same digest as in the cloud, and the same difference: /docs on in staging, off in production. Each environment also has its own data. The smoke check’s classify request wrote one row in each database, and /v1/history in staging lists only staging’s rows, with request IDs that production does not have.

Choose your route. You need the digest of your latest green CI run on main (from build-info.json).

  1. Add platform: linux/amd64 to the api service in deploy/local/compose.yaml.
  2. Add scripts/promote-local.sh and scripts/stop-local.sh, and make them executable.
  3. Simulated: run scripts/promote-local.sh staging ghcr.io/<you>/ticket-api-ci@<digest>. Expected: four ok lines, and staging now runs ….
  4. Simulated: run the same command with production. Expected: the same lines for production.
  5. Simulated: compare the two api containers with docker inspect and /docs, as in the worked example.
  6. Live: add .github/workflows/deploy.yml and deploy-to.yml. Commit everything on feature/deploy.
  7. Live: open the pull request feature/deploy into main. Wait for both required checks, then merge.
  8. Live: open Actions → Deploy. Watch staging pass, then approve production with a comment.
  9. Both: on a new branch feature/release-tags, remove the tag trigger from ci.yml, as in Configuration separation: A second build for the release. Commit it with docs/releases/1.1.0.md and release.yml, and merge the branch into main by pull request. (On the simulated route, the pull request of step 7 has no workflow files; merge it with the local files.)
  10. Both: when CI on main is green, tag the merge commit v1.1.0 and push the tag. Check that the Release run is green, and that imagetools inspect gives the same digest for :1.1.0 and :sha-<7>.
Show the expected result

Simulated: both scripts end with … now runs ghcr.io/<you>/ticket-api-ci@sha256:…, with your digest. docker inspect shows the same image ID for both api containers, and /docs gives 200 on 8081 and 404 on 8082.

Live: the Deploy run shows three green jobs, like the screenshot above. The staging job’s log has The new revision is ready. and the four ok lines. The run’s page lists your approval with its comment. Your apps’ /docs gives 200 in staging and 404 in production.

Both: the Release run is green, your repository has the release v1.1.0 with the image line, and :1.1.0 has the same digest as :sha-<7> of the tagged commit.

Predict, then (simulated) try it. Stop the local staging database, but leave its API running:

Terminal (simulated route)
docker stop ticket-api-staging-db-1
scripts/smoke.sh http://127.0.0.1:8081 "$(cat secrets/staging_api_key.txt)"

Which checks fail? What would the Deploy workflow do with production if this happened in the staging job? Start the database again with docker start ticket-api-staging-db-1.

Show the answer

The output (simulated):

ok health (200)
FAIL ready: expected 200, got 503
ok classify with the key (200)
ok classify without a key (401)

/health still answers 200: the process runs. /ready checks the database, so it answers 503 while the database is down, and smoke.sh exits with 1. Classify still passes, because the classifier does not need the database. One failed check is enough. In the pipeline, a failed smoke check fails the staging job. production has needs: [resolve, staging], so it never starts, and nobody is even asked to approve. Production keeps its old revision. That is the point of staging: a problem stops before it reaches the users. After docker start, the same smoke check passed again in the test.

Check your understanding

Where does the Deploy workflow get the digest that it deploys, and why does it refuse a value that does not start with sha256:?

Show the answer

From build-info.json, the artifact of the CI run on main that started the Deploy run (or from the digest input of a deployment by hand). CI built and tested exactly that image. The workflow refuses anything that is not a digest because a tag such as 1.1.0 or latest can be moved to other content; a digest always means the same bytes. So staging and production deploy what CI tested, and nothing else.

Check your understanding

Predict: the staging smoke check fails in a Deploy run. What happens to the production job, and what does production run afterwards?

Show the answer

The staging job fails. The production job needs the staging job, so GitHub skips it. It never waits for an approval and never signs in. Production keeps running its current revision, with the old digest. The new digest stops in staging until a fix comes through CI again.

Check your understanding

Why does release.yml use docker buildx imagetools create instead of docker build for the version tag? How can you check the result?

Show the answer

imagetools create gives the image that CI already built and tested (sha-92fff66) a second tag, 1.1.0. It builds nothing, so the release is the same artifact that staging checked and production runs. A build would make a new artifact that nobody tested. Check with docker buildx imagetools inspect on both tags: the Digest: lines must be the same (sha256:4eb3db8d… for 1.1.0).

Task: deploy the same artifact to two practice environments, local or hosted, and prove that only the settings differ.

  1. Take the digest of one green CI run on main.
  2. Deploy it to staging and check it: the live Deploy workflow, or scripts/promote-local.sh staging.
  3. Approve it for production (live: Review deployments, with a comment), or run scripts/promote-local.sh production (simulated).
  4. Collect the evidence in a short table. Give the digest, the smoke check result and the /docs status for each environment. Add who approved and why, and where each setting and secret lives.
  5. Then do it without the cloud or without the pipeline, whichever you did not do: read the other route’s evidence above, and write one sentence about what it proves that yours does not.
Show a model answer

The reference evidence for release 1.1.0:

Evidence Staging Production
Image (live, run 37665743749) ghcr.io/usmanahmed99/ticket-api-ci@sha256:4eb3db8d… the same
Image (simulated, docker inspect) sha256:4eb3db8d… the same
Smoke check 4 × ok 4 × ok
/docs 200 404
Setting that differs SHOW_DOCS=true (app setting; deploy/local/staging.env) SHOW_DOCS=false
Key Staging’s own: app secret api-key, GitHub environment secret SMOKE_API_KEY; secrets/staging_api_key.txt Production’s own, in the same places
Identity (live) id-deploy-staging, Container Apps Contributor on ticket-api-staging id-deploy-production, on ticket-api-production
Approval none needed usmanahmed99: “Release 1.1.0: staging smoke checks passed. Approved for production.”
Release Tag v1.1.0 = sha-92fff66 = sha256:4eb3db8d…

Only the live route proves these: a sign-in with no stored password, scoped roles, a recorded approval, and HTTPS on a real address. What the simulated route proves at no cost: one Compose project per environment, its own database, and the exact cloud image (by emulation).

Check your understanding

Retrieval: name the five things that may differ between staging and production in this module, and the one thing that must not.

Show the answer

May differ: the settings (such as SHOW_DOCS), the secret (each environment’s API key), and the identity that deploys, with its role. Also the data store and the rules: production has a required reviewer. Must not differ: the artifact, the image digest (and so the code and the packages in it).

Check your understanding

Explain or predict: a colleague turns off the required reviewer on production to save time. A merge to main has a bug that the smoke check does not see. What changes in the path to production, and which protections still work?

Show the answer

Without the reviewer, the production job starts as soon as staging passes. The digest reaches production about one minute after staging, and nobody looks at staging first. The other protections still work: the ruleset and the required checks before the merge, the build-once digest, the staging smoke check (a failure still stops production), the branch policy and the scoped identity. But the bug that the smoke check does not see goes straight to users. The approval is the place for a user-acceptance check in staging; keep it.

Check your understanding

Completion check: show that staging and production behave differently because of their settings only, without changing any application code.

Show the answer

Show the same digest in both environments: the Deploy log’s IMAGE: lines (live), or docker inspect --format '{{.Name}} {{.Image}}' on both api containers (simulated). Then show a difference that comes from a setting: /docs gives 200 in staging and 404 in production. Then change only the setting. Write SHOW_DOCS=false in deploy/local/staging.env, and run scripts/promote-local.sh staging with the same image. The test run printed Container ticket-api-staging-api-1 Recreate, the four ok lines, and then /docs gave 404 in staging. With SHOW_DOCS=true again, it gave 200, and the staging history still had its 4 rows: the database was not touched. No code, no image and no build changed. (Live: az containerapp update -n ticket-api-staging -g rg-ticket-api-ci --set-env-vars SHOW_DOCS=false changes the setting the same way; this was not run in the test.)

Recap

  • The Deploy workflow starts after CI succeeds on main, takes the digest from build-info.json, and promotes it: staging, the smoke check, the approval, then production with the same steps.
  • deploy-to.yml holds one environment’s steps: sign in (OIDC), az containerapp update --image, wait for the ready revision, smoke check, summary. environment: brings the variables, the secret and the rules.
  • A failure at any step stops the digest where it is. Production keeps its old revision.
  • The approval is public and traceable: who, which environment, and the comment.
  • release.yml tags the tested image with the version, without a build: 1.1.0 = sha-92fff66 = sha256:4eb3db8d….
  • Locally: promote-local.sh staging, then production. On Apple silicon, platform: linux/amd64 runs the cloud’s image by emulation.

Reset. Remove the local environments and their data, and the image:

Terminal
scripts/stop-local.sh staging --volumes
scripts/stop-local.sh production --volumes
docker image rm ghcr.io/<you>/ticket-api-ci@sha256:<digest>

The test run printed Removed for both api and db containers, both networks and both volumes. Live: Module 4 uses the two apps again, and they cost practically nothing while idle. If you stop here, delete everything with az group delete --name rg-ticket-api-ci --yes --no-wait. The GitHub environments, variables and releases cost nothing and can stay. To start Module 4 from a known state, use the snapshot end-of-m03 and make the GitHub settings of this module again.

Next, a change to the AI model itself: the pipeline must decide if a new classifier is good enough before any environment gets it.

Next: AI evaluation gate