Ch. 13 · Docker

Docker Image Tags and Reproducible Releases

Tags are movable pointers and digests are not. Learn why latest breaks rollbacks, how to tag releases, and when to pin images by sha256.

~5 min readintermediateupdated Oct 7, 2026

“Which version is running in production?” should have a one-word answer. With container images it often doesn’t, because the thing teams write in their deploy configs, a tag, is a label that can be moved to a different image at any time. Understanding the difference between tags and digests, and building a release process around it, is what this interview topic is really about.

The local examples below build two tiny Alpine images that differ only in a /version file.

A tag is a sticky note, not a version

Build an image and give it two names:

$ docker build -t shop:1.4.0 .
$ docker tag shop:1.4.0 shop:latest
$ docker image ls shop
IMAGE          ID             DISK USAGE   CONTENT SIZE
shop:1.4.0     de4e1581bc84       13.6MB          4.1MB
shop:latest    de4e1581bc84       13.6MB          4.1MB
Text

Same ID, two tags. Tagging copied nothing; it added a second name pointing at the same image. Now change the Dockerfile and build again as latest:

$ docker build -t shop:latest .
$ docker image ls shop
shop:1.4.0     de4e1581bc84
shop:latest    46f7d0ce43f1
$ docker run --rm shop:1.4.0 cat /version
v1
$ docker run --rm shop:latest cat /version
v2
Text

latest moved. Nothing about the name changed, but it refers to different content. Rebuild and push shop:1.4.0 from different code, and that tag moves too. Registries accept the overwrite unless you’ve turned on tag immutability (Amazon ECR, Google Artifact Registry and Harbor support it; it’s worth enabling for release tags).

There’s no magic in latest. It’s simply the tag Docker uses when you don’t write one: docker build -t shop . means shop:latest, and docker pull shop pulls shop:latest. It doesn’t mean “newest”. If someone pushed shop:2.0.0 without also tagging latest, latest still points at whatever was last pushed under that name.

What goes wrong with mutable tags

The failure stories cluster around three situations:

Rollbacks that don’t roll back. A deployment uses shop:latest. A bad release goes out, someone redeploys “the previous version”, but the previous config also said latest, so the same bad image comes back. Or worse, nodes that cached the old latest and nodes that pulled the new one run different code under the same name.

Base images that change under you. FROM node:22-bookworm is a tag the Node maintainers move whenever they publish a patch release or a Debian security update. That’s desirable for security, but it means two builds of the same commit a week apart can produce different images, and a breaking change in a base image shows up as “nothing changed but the build broke”.

Audits with no answer. If production runs api:stable, nobody can tell from the deploy history which code was live during last Tuesday’s incident.

Kubernetes adds a twist: with a :latest tag the default pull policy is Always, while for other tags it’s IfNotPresent, so a node that already has api:1.4.0 cached won’t pull a re-pushed api:1.4.0. Mutable non-latest tags are the worst of both worlds there.

Digests: the name that can’t move

Every image has a content digest, a SHA-256 hash of its manifest. Pull or push an image and Docker records it:

$ docker image inspect nginx:alpine --format '{{index .RepoDigests 0}}'
nginx@sha256:72ba65eb42c10344912a84ff42408db7d34f2feb642204570ab8fc5ffd29f1d3
Text

Reference an image by digest and you get exactly that content, every time, on every machine. If the bytes changed, the hash would change. You can combine both forms for readability; the digest wins:

FROM node:22-bookworm@sha256:<digest>
dockerfile
image: registry.example.com/shop@sha256:<digest>
yaml

The cost of pinning a base image by digest is that you no longer get its security patches automatically. That’s not a reason to avoid pinning; it’s a reason to automate updates. Tools like Renovate and Dependabot open pull requests that bump the digest, so updates arrive as reviewable, testable changes. The base image maintenance note covers that loop.

One more subtlety: image IDs and digests depend on more than your files. Building the same Dockerfile twice here produced different IDs (46f7d0ce43f1 and later cd6986d64936) because BuildKit records build metadata and timestamps. Byte-for-byte reproducible builds need extra work (SOURCE_DATE_EPOCH, pinned bases, pinned packages). For most teams the practical goal is weaker but sufficient: build once, and deploy the exact artifact you tested.

A release scheme that holds up

A tagging scheme that answers the “what’s running” question:

  1. Tag every CI build uniquely. shop:git-3f9c2ab or shop:build-1842. Never reuse it.
  2. Add human-friendly tags on release. shop:1.4.0, and optionally floating shop:1.4 and shop:1 that move to the newest patch. Floating tags are for people who want “latest 1.x”; they’re fine for consumers of your public image and wrong for your own deployments.
  3. Deploy by digest. CI records the digest that docker push prints and writes it into the deploy manifest. The tag in the manifest is a comment for humans.
  4. Promote, don’t rebuild. Staging and production run the same digest. “Promoting” means pointing production at the image staging already tested, maybe adding a prod tag for visibility, not running docker build again from the release branch.
  5. Make release tags immutable in the registry so a mistaken push fails instead of silently replacing a release.

With this, a rollback is “deploy the previous digest”, which is guaranteed to be the previous bits.

Interview practice

Your team deploys with image: payments:prod in Compose files on three VMs. A deploy job builds from main, pushes payments:prod and runs docker compose pull && docker compose up -d on each VM in turn. The job failed after the second VM. Now payments behaves differently depending on which VM handles a request, and someone wants to “roll back to yesterday’s prod”. What’s the state of the system, and how would you change the process?

Answer

Two VMs pulled the new image under payments:prod; the third still has yesterday’s image cached under the same tag. So the same name refers to two different images across the fleet. “Roll back to yesterday’s prod” isn’t possible by tag, because the registry’s payments:prod now points at today’s build. Yesterday’s image may still exist in the registry by digest, and the third VM’s local copy holds it; docker image inspect payments:prod --format '{{index .RepoDigests 0}}' on that VM shows its digest. Deploying that digest to all three VMs is the immediate rollback.

For the process: tag each build uniquely (Git SHA), push, capture the digest, and deploy payments@sha256:... everywhere. Make deploys converge on a specific digest, so a partial failure is visible (“2 of 3 on digest B”) and rerunnable. Keep prod as an informational tag at most, and enable tag immutability for release tags.

Keep going

More in Docker

esc