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: b77a54c1dd2a316d91c232bc69b0ed7dfb8102d5417219b5b18d3fdc07d7957e

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 cannot move. This lesson reproduces the incident with a local registry, then does the promotion properly. It 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.22, pinned to the index digest the tag pointed at when this lab was written
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
ARG BUILD=1
RUN echo "lab-app build $BUILD" > /build.txt
CMD ["cat", "/build.txt"]

In name:tag@sha256:... the digest decides what is used and 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.8.2
$ grep '^FROM' Dockerfile docker buildx imagetools inspect alpine:3.22 --format '{{.Manifest.Digest}}'
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8

--format '{{.Manifest.Digest}}' prints the digest of whatever the tag resolves to, which for alpine is its index ("Image anatomy: index, manifest, config and layers" explains the index). When this lab ran, the two values matched. The next time the Alpine maintainers rebuild 3.22 for a security fix, the tag moves and the lines differ, while builds from this Dockerfile keep using the pinned bytes until someone changes the pin on purpose. Moving pins forward on a schedule with Renovate or Dependabot is covered in "Pinning, SBOMs, provenance and scanning" in Advanced container security.

Pin the index digest, the one the tag resolves to. Every 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 that restriction is intended, 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 no TLS setup is needed for the lab:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.8.2
$ docker network create lab-net docker run -d --name lab-registry --network lab-net -p 127.0.0.1:5000:5000 registry:3
c5518e0fa2bf3433f2298fd9b76d722ecab0ec028648e933af8484eb223d5208 87f3a6b94068463acaa212049dc00947c210e461135d6d7bd12967edeb40fddd

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.8.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:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c The push refers to repository [localhost:5000/lab-staging/app] 46b3f045b8b7: Pushed 44136fa355b3: Pushed 04cd7036f8d0: Pushed 16fc4f52163f: Pushed 1.0: digest: sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c size: 855
$ docker buildx imagetools inspect localhost:5000/lab-staging/app:1.0 \ --format '{{.Manifest.Digest}}' | tee tested.digest
sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c

The push ends with 1.0: digest: sha256:..., and the lookup prints the same value: the index digest, because BuildKit attached a provenance attestation and made the result an index. tee keeps it in tested.digest, playing the part of the release record. Now the afternoon fix arrives under the same tag:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.8.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:51cd2127ecd17bf86db3107e27a1694f8a55c65b0f423531368d643fe516acc5 The push refers to repository [localhost:5000/lab-staging/app] 74fe4adb039e: Pushed 860e750e42a4: Pushed 44136fa355b3: Already exists 16fc4f52163f: Layer already exists 1.0: digest: sha256:51cd2127ecd17bf86db3107e27a1694f8a55c65b0f423531368d643fe516acc5 size: 855
$ docker buildx imagetools inspect localhost:5000/lab-staging/app:1.0 --format '{{.Manifest.Digest}}'
sha256:51cd2127ecd17bf86db3107e27a1694f8a55c65b0f423531368d643fe516acc5

Same tag, new digest. The registry accepted the overwrite without complaint, and only the alpine layer and the empty attestation config were reused. Anyone who pulls 1.0 from now on gets build 2, and anyone holding the digest still gets build 1:

ubuntu@secopslog-docker:~/lab/tagsdigests · Docker 29.8.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:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c: Pulling from lab-staging/app 46b3f045b8b7: Pulling fs layer 04cd7036f8d0: Download complete 44136fa355b3: Already exists 46b3f045b8b7: Already exists 46b3f045b8b7: Pull complete Digest: sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c Status: Downloaded newer image for localhost:5000/lab-staging/app@sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c localhost:5000/lab-staging/app@sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c
$ 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 is how Compose files and Kubernetes manifests should name production images. Most of the layers were already present, because build 1's content was still in the local content store and only the tag had moved off it, so the pull finished almost at once.

Image ID, RepoDigests and platform digests

Deploy scripts often compare "the image we meant" with "the image we got". Know which fields hold what:

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

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: 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. Compare digests from the registry or from RepoDigests, not raw IDs, if your fleet mixes old and new hosts.

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

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

On this arm64 VM the only runnable entry is linux/arm64; built on amd64 it would be linux/amd64, and a multi-platform build ("Multi-platform images") lists both. Record and promote the index digest, never the inner manifest digest, or 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 are usually separate registries or projects with separate 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" is not "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.8.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:8752340fdd5219728c5290936b59d340f349aea8f1872a5681b767a2d96e0bcd from localhost:5000/lab-staging/app@sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c to localhost:5000/lab-prod/app #1 0.001 copying sha256:1db6928ed47af2c4332eac154d00cf69dc4515ddef762798bb4eb64a8a35de77 from localhost:5000/lab-staging/app@sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c to localhost:5000/lab-prod/app #1 0.220 pushing sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c to localhost:5000/lab-prod/app:1.0 #1 DONE 0.3s

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 a 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.8.2
$ sh check-digest.sh localhost:5000/lab-prod/app:1.0 tested.digest
OK localhost:5000/lab-prod/app:1.0 -> sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c

A reachable registry is not an approval

Promotion by digest only protects you if it is the only 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.8.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] 74fe4adb039e: Waiting 16fc4f52163f: Layer already exists 860e750e42a4: Waiting 44136fa355b3: Already exists 74fe4adb039e: Mounted from lab-staging/app 860e750e42a4: Mounted from lab-staging/app 1.0: digest: sha256:51cd2127ecd17bf86db3107e27a1694f8a55c65b0f423531368d643fe516acc5 size: 855
$ sh check-digest.sh localhost:5000/lab-prod/app:1.0 tested.digest
FAIL localhost:5000/lab-prod/app:1.0 -> sha256:51cd2127ecd17bf86db3107e27a1694f8a55c65b0f423531368d643fe516acc5 tested image is sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c

The push succeeded and production silently changed; only the check noticed. The lesson 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 does not), deploy by digest so a moved tag cannot 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, and the full pipeline in "Docker in CI/CD: build to promote"). Network access to a registry is a transport, not a decision.

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.8.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] 04cd7036f8d0: Already exists 16fc4f52163f: Layer already exists 46b3f045b8b7: Layer already exists 44136fa355b3: Already exists 1.0: digest: sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c size: 855
$ sh check-digest.sh localhost:5000/lab-prod/app:1.0 tested.digest
OK localhost:5000/lab-prod/app:1.0 -> sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c

Nothing was uploaded, since the repository already had every blob, and the pushed digest equals the tested one, attestation included. With the containerd store, pushing a pulled image keeps its index intact; 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 cannot 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.8.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:51cd2127ecd17bf86db3107e27a1694f8a55c65b0f423531368d643fe516acc5 Untagged: localhost:5000/lab-prod/app:1.0 Untagged: localhost:5000/lab-staging/app@sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c Deleted: sha256:fa7bff46991424dae09e9eb53a56d53b41eede9251ce65c8543d155d30b0230c
Quick check
01A Dockerfile has FROM python:3.14-slim@sha256:<digest>, where the digest was copied from the linux/amd64 entry of docker buildx imagetools inspect python:3.14-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.

Try this

Work through “A reachable registry is not an approval” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from tags, digests and promotion, keep “A reachable registry is not an approval”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related