Tags, digests and promotion

Mutable tags, immutable digests and promoting by digest.

Intermediate14 min · lesson 2 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (2 files, 1 KB): tagsdigests.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/tagsdigests.tar.gz && tar -xzf tagsdigests.tar.gz, which creates ~/lab/tagsdigests/. SHA-256: d640703a33ca45310164aee608f77925d6ab6c03c6182898216014df8ff25a20

QA signs off on app:1.0 in staging at two in the afternoon. At three, a developer rebuilds a quick fix and pushes it under the same tag, because the version number "has not shipped yet". At four, the release job copies app:1.0 from staging to production. Production now runs an image nobody tested, and every dashboard says 1.0. Nothing in that story is a bug in Docker. A tag is a name that anyone with push rights can move. The fix is to record and promote the digest, which can't move. You'll reproduce the incident with a local registry, then do the promotion properly. Everything runs on the main lab VM, secopslog-docker, with the lesson files (a Dockerfile and a small check script) in ~/lab/tagsdigests.

Pinning the base image

Start where most images start, the FROM line. The lab Dockerfile pins alpine by tag and digest:

Dockerfile
# alpine:3.24, pinned to the index digest the tag pointed at when this lab was written
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG BUILD=1
RUN echo "lab-app build $BUILD" > /build.txt
CMD ["cat", "/build.txt"]

In name:tag@sha256:... the digest decides what's used. The tag is only there for people reading the file. Compare the pin with what the tag points at today:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ grep '^FROM' Dockerfile docker buildx imagetools inspect alpine:3.24 --format '{{.Manifest.Digest}}'
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a

--format '{{.Manifest.Digest}}' prints the digest of whatever the tag resolves to. For alpine that's its index ("Image anatomy: index, manifest, config and layers" explains the index). When this lab ran, the two values matched. Next time Alpine rebuilds 3.24 for a security fix, the tag moves and the lines differ. Your builds keep using the pinned bytes until someone changes the pin on purpose. "Pinning, SBOMs, provenance and scanning" in Advanced container security shows you how to move pins forward on a schedule with Renovate or Dependabot.

Pin the index digest, the one the tag resolves to. Each builder then picks its own platform from the index. So the same line works on an amd64 laptop and an arm64 CI runner. The digest of one platform's manifest (an entry inside the index) pins that architecture only. An arm64 builder given an amd64 manifest digest builds for amd64 or fails. Use a platform digest only when you mean to limit it that way, and write the reason next to it.

One tag, two builds

Start a registry:3 container on a lab network, published on the VM's loopback address. Docker trusts localhost registries over plain HTTP, so you don't need any TLS setup for the lab:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker network create lab-net docker run -d --name lab-registry --network lab-net -p 127.0.0.1:5000:5000 registry:3
bde1a2c818fc7c598dc1dab27e84357f115d098556bc21a5b97b22d6ed9f1706 2fd6d9c09a8cf133c9efef3a83321a7c21b38cd3852a9a0ee7c34288110d3dd7

Build the image, push it to the staging repository, and record its digest. This is the build QA tests:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker build -q --build-arg BUILD=1 -t localhost:5000/lab-staging/app:1.0 . docker push localhost:5000/lab-staging/app:1.0
sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb The push refers to repository [localhost:5000/lab-staging/app] fb5249218f22: Pushed 3dba4b0f37c5: Pushed 44136fa355b3: Pushed d3531b7a4f21: Pushed 1.0: digest: sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb size: 855
$ docker buildx imagetools inspect localhost:5000/lab-staging/app:1.0 \ --format '{{.Manifest.Digest}}' | tee tested.digest
sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb

The push ends with 1.0: digest: sha256:..., and the lookup prints the same value. It's the index digest, because BuildKit attached a provenance attestation and made the result an index. tee saves it in tested.digest. That file is your release record. Now the afternoon fix arrives under the same tag:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker build -q --build-arg BUILD=2 -t localhost:5000/lab-staging/app:1.0 . docker push localhost:5000/lab-staging/app:1.0
sha256:f9d58fd84c77d7d6d36da881b1135eccd5d948c768507ad7b318ff59bfee2596 The push refers to repository [localhost:5000/lab-staging/app] fd8a6d41990e: Pushed f22f40c5a3e4: Pushed 44136fa355b3: Already exists d3531b7a4f21: Layer already exists 1.0: digest: sha256:f9d58fd84c77d7d6d36da881b1135eccd5d948c768507ad7b318ff59bfee2596 size: 855
$ docker buildx imagetools inspect localhost:5000/lab-staging/app:1.0 --format '{{.Manifest.Digest}}'
sha256:f9d58fd84c77d7d6d36da881b1135eccd5d948c768507ad7b318ff59bfee2596

Same tag, new digest. The registry took the overwrite and said nothing. Only the alpine layer and the empty attestation config were reused. Anyone who pulls 1.0 from now on gets build 2. Anyone holding the digest still gets build 1:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker run --rm localhost:5000/lab-staging/app:1.0
lab-app build 2
$ docker pull localhost:5000/lab-staging/app@$(cat tested.digest)
localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb: Pulling from lab-staging/app fb5249218f22: Pulling fs layer d3531b7a4f21: Pulling fs layer 3dba4b0f37c5: Download complete 44136fa355b3: Already exists fb5249218f22: Already exists d3531b7a4f21: Already exists fb5249218f22: Pull complete Digest: sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb Status: Downloaded newer image for localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb
$ docker run --rm localhost:5000/lab-staging/app@$(cat tested.digest) docker run --rm localhost:5000/lab-staging/app:1.0@$(cat tested.digest)
lab-app build 1 lab-app build 1

The third command uses name:tag@digest. The tag says 1.0, which is build 2, and the container still prints build 1. When a reference carries a digest, Docker resolves the digest and ignores the tag. That's how your Compose files and Kubernetes manifests should name production images. Most of the layers were already present, so the pull finished almost at once. Build 1's content was still in the local content store; only the tag had moved off it.

Image ID, RepoDigests and platform digests

Deploy scripts often compare "the image we meant" with "the image we got". So you need to know which field holds what:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker image inspect localhost:5000/lab-staging/app@$(cat tested.digest) \ --format 'Id: {{.Id}}{{println}}RepoDigests: {{json .RepoDigests}}'
Id: sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb RepoDigests: ["localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb"]

On the containerd image store, the default on fresh Docker 29 installs, the image ID is the index digest. So the ID and the registry digest agree, as here. On hosts still using the legacy graph-driver store, the ID is the digest of the image config, which no registry reports ("Inspecting, exporting and cleaning up images" shows it). RepoDigests is the portable field. It holds repository@digest for every registry this image is known to have come from, on both stores. A running container records the ID in docker inspect --format '{{.Image}}' CONTAINER. If your fleet mixes old and new hosts, compare digests from the registry or from RepoDigests, not raw IDs.

The digest you recorded names an index. Inside it are the platform manifest and its attestation:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker buildx imagetools inspect localhost:5000/lab-staging/app@$(cat tested.digest)
Name: localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb Manifests: Name: localhost:5000/lab-staging/app@sha256:b137a32538174cb9196b67a715729438d30866eb87dd307e9e11aa9b4055c269 MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/arm64 Name: localhost:5000/lab-staging/app@sha256:0e703cb6db13d8e7fb50e7b85655ff917680d3701e9b4bb35c9ba9c31a0d7a40 MediaType: application/vnd.oci.image.manifest.v1+json Platform: unknown/unknown Annotations: vnd.docker.reference.digest: sha256:b137a32538174cb9196b67a715729438d30866eb87dd307e9e11aa9b4055c269 vnd.docker.reference.type: attestation-manifest

On this arm64 VM the only runnable entry is linux/arm64. Built on amd64 it'd be linux/amd64, and a multi-platform build ("Multi-platform images") lists both. Record and promote the index digest, never the inner manifest digest. Otherwise the attestation and any other platforms are left behind.

Promotion by digest

The lab registry has two repositories standing in for two environments: lab-staging/app and lab-prod/app. In a real setup they're often two registries or projects, each with its own credentials. Promotion copies the tested digest to production. It never rebuilds. A rebuild from the same commit produces a different digest anyway, because the config records the build time. So "the same source" isn't "the same image". docker buildx imagetools create copies an image between repositories registry-side, without pulling it into the local store:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker buildx imagetools create \ --tag localhost:5000/lab-prod/app:1.0 \ localhost:5000/lab-staging/app@$(cat tested.digest)
#1 [internal] pushing localhost:5000/lab-prod/app #1 0.000 copying sha256:0e703cb6db13d8e7fb50e7b85655ff917680d3701e9b4bb35c9ba9c31a0d7a40 from localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb to localhost:5000/lab-prod/app #1 0.000 copying sha256:b137a32538174cb9196b67a715729438d30866eb87dd307e9e11aa9b4055c269 from localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb to localhost:5000/lab-prod/app #1 0.043 pushing sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb to localhost:5000/lab-prod/app:1.0 #1 DONE 0.1s

The two copying lines are the manifests the index lists, the arm64 image and its attestation, each with the blobs it references. The last line pushes the index itself under the new tag. The image never passed through the local store. The check script turns "is production running what we tested" into an exit status your pipeline can act on:

check-digest.sh
#!/bin/sh
# Usage: ./check-digest.sh IMAGE:TAG DIGEST_FILE
# Exits 0 only when IMAGE:TAG currently resolves to the digest recorded in DIGEST_FILE.
want=$(cat "$2")
have=$(docker buildx imagetools inspect "$1" --format '{{.Manifest.Digest}}')
if [ "$have" = "$want" ]; then
echo "OK $1 -> $have"
else
echo "FAIL $1 -> $have"
echo " tested image is $want"
exit 1
fi
ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ sh check-digest.sh localhost:5000/lab-prod/app:1.0 tested.digest
OK localhost:5000/lab-prod/app:1.0 -> sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb

A reachable registry is not an approval

Promotion by digest only keeps you safe if it's the one way into production. The registry has no idea which images were tested. Anyone who can reach it with push rights can overwrite the production tag, here with the untested build 2:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker tag localhost:5000/lab-staging/app:1.0 localhost:5000/lab-prod/app:1.0 docker push localhost:5000/lab-prod/app:1.0
The push refers to repository [localhost:5000/lab-prod/app] 44136fa355b3: Already exists d3531b7a4f21: Layer already exists fd8a6d41990e: Mounted from lab-staging/app f22f40c5a3e4: Mounted from lab-staging/app 1.0: digest: sha256:f9d58fd84c77d7d6d36da881b1135eccd5d948c768507ad7b318ff59bfee2596 size: 855
$ sh check-digest.sh localhost:5000/lab-prod/app:1.0 tested.digest
FAIL localhost:5000/lab-prod/app:1.0 -> sha256:f9d58fd84c77d7d6d36da881b1135eccd5d948c768507ad7b318ff59bfee2596 tested image is sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb

The push succeeded and production silently changed. Only the check noticed. What matters is the order of controls, not the script. Give production push rights to the promotion job alone. Turn on immutable tags where the registry offers them (Harbor, Amazon ECR, Azure Container Registry and Google Artifact Registry do; registry:3 doesn't). Deploy by digest, so a moved tag can't change a running service. And verify a signature made over the tested digest before anything runs. "Signing, verifying and promoting by digest" in Advanced container security covers that, and "Docker in CI/CD: build to promote" shows the full pipeline. Being able to reach a registry is a path in, not a sign-off.

Put production back on the tested image. The plain docker tag and docker push route works too, as long as the source is the digest and not the tag:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker tag localhost:5000/lab-staging/app@$(cat tested.digest) localhost:5000/lab-prod/app:1.0 docker push localhost:5000/lab-prod/app:1.0
The push refers to repository [localhost:5000/lab-prod/app] 44136fa355b3: Already exists d3531b7a4f21: Layer already exists fb5249218f22: Layer already exists 3dba4b0f37c5: Already exists 1.0: digest: sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb size: 855
$ sh check-digest.sh localhost:5000/lab-prod/app:1.0 tested.digest
OK localhost:5000/lab-prod/app:1.0 -> sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb

Nothing was uploaded, since the repository already had every blob. The pushed digest equals the tested one, attestation included. With the containerd store, pushing a pulled image keeps its index intact, but you should check the printed digest anyway. A host on the legacy store keeps only the platform image, not the index. So a re-push from there can't reproduce the original digest. imagetools create avoids the question by never going through the local store. Clean up:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.9.2
$ docker rm -f -v lab-registry docker network rm lab-net docker image rm localhost:5000/lab-staging/app:1.0 localhost:5000/lab-prod/app:1.0 \ localhost:5000/lab-staging/app@$(cat tested.digest) rm tested.digest
lab-registry lab-net Untagged: localhost:5000/lab-staging/app:1.0 Deleted: sha256:f9d58fd84c77d7d6d36da881b1135eccd5d948c768507ad7b318ff59bfee2596 Untagged: localhost:5000/lab-prod/app:1.0 Untagged: localhost:5000/lab-staging/app@sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb Deleted: sha256:fce429d78819ca94f8e95b51756953950eca32f2b9a3efda5a0b2882dafe15fb

Your turn

Before the cleanup, point the check script at staging instead: sh check-digest.sh localhost:5000/lab-staging/app:1.0 tested.digest. Predict whether it prints OK or FAIL and which exit status you'll get, then compare with the check2 output above. Staging's 1.0 still names the afternoon rebuild, so the tag you'd naively promote is the wrong one.

Bottom line

Deploy and promote by the index digest, never by the tag: the bypass push overwrote lab-prod/app:1.0 with build 2 and the registry said nothing. A digest check only catches that if it runs after every push, and only immutable tags plus push rights limited to the promotion job stop it from happening. Rebuilding from the same commit is no substitute, because the new config gets a new digest.

Quick check
01A Dockerfile has FROM python:3.15-slim@sha256:<digest>, where the digest was copied from the linux/amd64 entry of docker buildx imagetools inspect python:3.15-slim. Builds pass on amd64 laptops; the new arm64 CI runners produce images that crash with exec format error. What happened?
Incorrect — Digests do not expire, and a digest in the reference always wins over the tag.
Incorrect — BuildKit honours the digest on every platform; that is why the runners got exactly the amd64 bytes.
Correct — arm64 runners got amd64 bytes. Pin the index digest that the tag resolves to, and each builder picks its own platform.
Incorrect — With a digest present the tag is ignored, so a moved tag changes nothing here.
02Staging tested registry.example.com/staging/api@sha256:<tested>. The release job runs docker buildx imagetools create --tag registry.example.com/prod/api:2.4 registry.example.com/staging/api:2.4. Why is this risky?
Correct — That may not be the tested digest. Promote the recorded digest; a tag can move between test and promotion, as the lab showed.
Incorrect — It copies manifests and blobs; the lab showed an identical digest in production.
Incorrect — Copying the index carries its attestation entries along.
Incorrect — It works registry-side; nothing is pulled into the local image store.
03A deploy script compares docker image inspect app:2.4 --format '{{.Id}}' with the digest the registry reports for the tag. It passes on new hosts and always fails on hosts upgraded from Docker 27. Why?
Incorrect — Possibly, but it would not explain why the ID differs on every image.
Incorrect — The script compares a whole-image identifier; platform choice is not the cause.
Incorrect — A digest is a hash of the content; the registry does not tailor it per client.
Correct — Only on the containerd store does the ID equal the index digest. Compare RepoDigests, which carries the registry digest on both stores.

Related