CoursesDocker in depthTags, digests & pinning

Tags, digests & pinning

Mutable names vs immutable content.

Intermediate10 min · lesson 3 of 30

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

terminal
$ docker buildx imagetools inspect registry.internal/payments-api:1.4.2
Name: registry.internal/payments-api:1.4.2
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:8c30969864f0bac631e076436bdb7c9e132d46d4b799fcdd4dfc840c32d0a4ce
$ docker images --digests registry.internal/payments-api
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
registry.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

terminal
$ docker pull node@sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9
docker.io/library/node@sha256:02d429f4...: Pulling from library/node
6e771e15690e: Pull complete
fa2b0f0c3e9d: Pull complete
Digest: sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9
Status: Downloaded newer image for node@sha256:02d429f4...
docker.io/library/node@sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9
$ docker inspect --format '{{index .RepoDigests 0}}' node@sha256:02d429f4d6a6d106babc400013ab847197eaaf60d6f8366ffa83c92baca593d9
node@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.

Dockerfile
# floating: today's node:22-alpine may not match tomorrow's
FROM node:22-alpine
# pinned: the exact base bytes, rebuild-stable, bumped on review
FROM node:22-alpine@sha256:0c5fa55f0db8963c42b65455395a7bb55e4072ec1ad2366cf1cf1270ef19d554
WORKDIR /app
COPY . .
terminal
$ 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.

Same tag, different moments
docker pull payments-api:1.4.2
one tag, pulled at two times
Monday
@sha256:8c3096…
the image you tested
Friday, after a re-push
@sha256:5a7c44…
different bytes, same tag
pin the digest
@sha256:8c3096… always
every pull, every node, identical
A tag is a fork in the road. The same name can resolve to different bytes depending on when you pulled. Pin the digest and the fork disappears.
A digest can name one CPU, or all of them
Popular base images like node:22-alpine are not one image. The tag points at a manifest list, also called an index: a tiny file that says amd64 (the Intel and AMD chips in most laptops and servers) lives at this child digest, arm64 (Apple silicon, AWS Graviton, a Raspberry Pi) at that one. Run docker buildx imagetools inspect node:22-alpine and you get a top-level Digest for the index, then a Manifests section holding one child digest per architecture. Pin that top-level index digest, which is also the value a plain docker pull records under docker images --digests, and FROM quietly resolves the right child for whatever CPU is doing the building. Reach into the Manifests section and pin a child digest instead, say the linux/amd64 line, and you have nailed the base to one chip family. The build sails through on your amd64 laptop, then dies on an arm64 runner that finds no matching platform. Copy the top Digest, never one of the per-arch children.

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.

terminal
$ 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 $DIGEST
Digest: sha256:4f8c1a9b2d4e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f8
Status: 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.

Quick check
01Your Dockerfile pins FROM node:22-alpine@sha256:… using a digest you copied out of the Manifests section of docker buildx imagetools inspect node:22-alpine, specifically the linux/amd64 line. It builds fine on your amd64 laptop and fails the moment an arm64 CI runner builds the same file. What went wrong?
Correct — The Manifests section lists one child per architecture, and pinning the amd64 child hard-wires amd64. Pin the top-level index Digest instead and each builder resolves its own arch.
Incorrect — No. A digest is a content hash, not a lease. It never expires, and the same digest always names the same bytes.
Incorrect — No. FROM takes name:tag@sha256:… fine, and the local build that used the very same line already succeeded.
Incorrect — No. Content addressing guarantees the opposite: one digest means identical bytes everywhere, which is the whole reason to pin one.
02What really separates a digest like sha256:8c3096… from a tag like 1.4.2?
Incorrect — A digest can't be repointed; it's derived from the content, not chosen by a person.
Correct — Content-addressing ties the digest to the bytes, which is why it's immutable while a tag is a movable label.
Incorrect — Nothing about a digest is sequential; it's a content hash, independent of push order.
Incorrect — A digest is a content hash, not a lease; it never expires and always names the same bytes.
03One node is misbehaving while its twin runs fine, and both report payments-api:1.4.2. What most likely happened, and what removes this kind of bug for good?
Incorrect — Nothing here points to architecture; the symptom is two different builds under one tag.
Incorrect — Digests don't expire, and if they'd pinned a digest both nodes would be identical.
Correct — A tag is mutable, so the same name can resolve to different images at different moments; a digest can't.
Incorrect — There's no sign of corruption; the ordinary explanation is a moved tag, which a re-pull wouldn't reliably fix.

Related