Tags, digests & pinning
Mutable names vs immutable content.
A tag is a sticky note. Somebody wrote payments-api:1.4.2 on it and stuck it to a box of bytes, and anyone with push access can peel it off tomorrow and stick it on a different box. A digest is the box's fingerprint: the letters sha256: followed by a hash (a fixed-length fingerprint calculated from the contents) of the image manifest (the small file listing which layers make up the image). Change one byte anywhere inside and the fingerprint changes completely. A digest therefore names exactly the content it was calculated from, and nothing else. Tags are what you read out loud and paste into a ticket. When you need a machine to run the precise thing you tested, you deploy by digest.
Find the digest behind a tag
$ docker buildx imagetools inspect registry.internal/payments-api:1.4.2Name: registry.internal/payments-api:1.4.2MediaType: application/vnd.oci.image.index.v1+jsonDigest: sha256:8c30969864f0bac631e076436bdb7c9e132d46d4b799fcdd4dfc840c32d0a4ce$ docker images --digests registry.internal/payments-apiREPOSITORY TAG DIGEST IMAGE ID CREATED SIZEregistry.internal/payments-api 1.4.2 sha256:8c309698... a1b2c3d4e5f6 3 days ago 142MB
That hash is the coordinate you actually want. A reference can carry both halves at once, payments-api:1.4.2@sha256:8c3096…, and each half does a different job. The tag is there so a person can tell at a glance what this is. The part after @sha256: is what Docker resolves and pulls. You can also pull by digest alone, with no tag at all, which is the honest test of whether a registry (the server your images live on and get pulled from) hands back the bytes you asked for and nothing extra.
Pull by digest and verify you got it
$ docker pull node@sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9docker.io/library/node@sha256:02d429f4...: Pulling from library/node6e771e15690e: Pull completefa2b0f0c3e9d: Pull completeDigest: sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9Status: Downloaded newer image for node@sha256:02d429f4...docker.io/library/node@sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9$ docker inspect --format '{{index .RepoDigests 0}}' node@sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9node@sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9
RepoDigests is the list of digests this local image is known by back at the registry. When the entry there matches the digest you asked for, you have proof: the bytes now sitting on this disk are the ones you named, not whatever a lookalike tag was pointing at. Put that comparison in your deploy script and a swapped image cannot slide past you in silence.
latest, and every other tag, can move
latest sounds like it means the newest release. It doesn't. It means whatever was pushed last under that name, and it moves with no announcement, no email, no ceremony. Ordinary version tags move too. A team re-cuts 1.4.2 to slip in a hotfix, and now two servers that both report 1.4.2 are running different code. You have probably chased this ghost. A service misbehaves on one node, works fine on its twin, and both swear they are on the same version. They pulled the same tag at different moments. Pinning the digest kills that whole family of bug, because the digest is the bytes. Some registries can lock a tag so nobody can overwrite it. You cannot count on every registry in your supply chain doing that.
The same trick plays out one floor down, inside your build. FROM node:22-alpine floats. A nightly rebuild that passed every night for a month suddenly ships a fresh CVE (Common Vulnerabilities and Exposures, the public catalogue of known security flaws) because the base tag was repointed at a rebuilt image. Pin the base by its digest and your rebuilds stay reproducible. Bump it on purpose, after you have read what changed.
# floating: today's node:22-alpine may not match tomorrow'sFROM node:22-alpine# pinned: the exact base bytes, rebuild-stable, bumped on reviewFROM node:22-alpine@sha256:0c5fa55f0db8963c42b65455395a7bb55e4072ec1ad2366cf1cf1270ef19d554WORKDIR /appCOPY . .
$ docker build -t payments-api:1.4.2 .[+] Building 6.1s (9/9) FINISHED docker:default=> [internal] load build definition from Dockerfile 0.0s=> => transferring dockerfile: 218B 0.0s=> [internal] load metadata for docker.io/library/node:22-alpine@sha256:0c5fa55f 0.4s=> [1/3] FROM docker.io/library/node:22-alpine@sha256:0c5fa55f0db8963c42b65455395a7bb55e4072ec1ad2366cf1cf1270ef19d554 0.0s=> CACHED [2/3] WORKDIR /app 0.0s=> CACHED [3/3] COPY . . 0.0s=> exporting to image 0.1s=> => writing image sha256:5c1f8e9a2b4d7f0e3a6c9d2b5e8f1a4c7d0b3e6f9a2c5d8e1b4a7f0c3d6e9b2a 0.0s=> => naming to docker.io/library/payments-api:1.4.2 0.0s
The metadata line names the base by the digest you pinned, so every builder (your laptop, the CI runner that rebuilds on each push, a colleague five time zones away) resolves the identical base image. Nobody wakes up to find their build quietly drifted onto a newer alpine overnight.
Tags move; digests do not
So carry both in your deploy manifests. The tag is for the person reviewing the pull request, the digest is what the machine acts on. Pulling by digest gives you two outcomes and only two: you get that exact set of layers, or the pull fails. There is no quiet third case where you get something close enough. After rollout, close the loop by checking the running container's ImageID against the digest you meant to ship, because a deploy pinned to a floating tag is a bet that nothing moved between staging and production.
latest is the loudest offender and far from the only one. stable moves. alpine moves. So does 1.27 the day the maintainer republishes it. A tag shaped like a version number feels safe, and that feeling is exactly what makes it risky. The price of digest-only pinning is readability, since a hash is impossible to say over a call and unpleasant to eyeball in a diff, so most teams pin the digest in Git and keep the tag next to it as a comment or an annotation for humans. Check one more link in the chain: your registry mirror and your CI cache have to honour digests too. A cache keyed only on the tag puts the race straight back in.
Here is the shape of a bad Friday. Staging pulls nginx:1.27 at noon. Production pulls at six, after an upstream rebuild has landed. Staging passed everything; production falls over on a config default that changed in the new layers. With no digests in the audit trail, you get an hour of smart people arguing about a version number they genuinely share. With digests, two hashes go into the channel and the argument is finished. On-call runbooks should carry docker image inspect --format '{{.RepoDigests}}' and a link to the SBOM (software bill of materials, the itemised list of what is inside the image) for that digest, not only the tag string copied out of the ticket.
Retags and promotions need the same discipline. CI builds app:git-sha, then later that artifact becomes app:staging. Promote it by copying the digest (regctl image copy, crane copy, or your registry's promote API, meaning its own interface for moving an image between repositories) instead of rebuilding from the same commit. A rebuild can land on a different digest even from identical source, because build timestamps shift and base layers shift. Promotion by copy means the artifact that left CI is byte-identical to the one production runs, which is the bar an incident review or a supply-chain audit will hold you to.
Multi-architecture images add one wrinkle here as well. A tag usually points at a manifest list, so the digest you pin should be the list digest your platform resolves, or an explicit per-platform digest if you only ever deploy amd64. Running a list digest on one cluster and a platform digest on another is fine as long as it is written down somewhere findable. Mixing tags across architectures without ever checking RepoDigests is how "it works on my machine" comes back wearing a cloud costume. Print RepoDigests after every pull, on the same architecture you will actually run.
When you do this for real, write down three things: the digest that was running before, the digest you moved to, and the host you ran the commands on. Two hashes and a hostname turn a rollback into one docker pull of the old digest instead of an archaeology dig through chat history. If a teammate cannot repeat your pin and your RepoDigests check from the ticket alone, with the exact flags and the digest line you saw on a healthy host, the runbook is not finished yet.
Try this
Run these on a throwaway engine rather than anything that serves traffic (Docker 24 or newer is fine). Read the sample output first, so you know what a healthy result looks like before you lean on the command somewhere that matters.
$ docker pull nginx:1.27-alpine$ docker image inspect nginx:1.27-alpine --format '{{json .RepoDigests}}'["nginx@sha256:4f8c1a9b2d4e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8"]$ DIGEST=$(docker image inspect nginx:1.27-alpine --format '{{index .RepoDigests 0}}')$ docker pull $DIGESTDigest: sha256:4f8c1a9b2d4e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8Status: Image is up to date for nginx@sha256:4f8c1a9b2d4e…# same digest twice → same bytes; retag tomorrow cannot change what you pinned
Takeaway
Tags are nicknames. Digests are identity. Pin production by digest and keep the tag beside it for the humans, move artifacts between environments by copying the digest rather than rebuilding, and print RepoDigests on the node that actually runs the workload. Any tag can move under you overnight, latest loudest of all, and plenty of ones that look like tidy version numbers.