Docker & ContainersIV Β· ImagesTags
Tags β mutable references to digests
What you'll learn
- Explain what a tag is to the daemon and why nothing enforces its meaning
- Name the ways `latest` behaves differently from every other tag
- Detect tag drift across a fleet with a check that fails
- Choose a pinning strategy proportionate to the risk
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
A tag is a string a publisher attaches to a digest in their registry. That is the entire definition. It carries no version semantics, nothing validates it, and the person who set it can change it at any moment without telling you.
Every problem in this lesson follows from the gap between that reality and the
way a tag looks in a Compose file β where nginx:1.27 sits in exactly the
position a version number sits, and behaves nothing like one.
What a tag is to the daemon
Tag conventions, and how far to trust them
| Tag style | Stable in practice? | Notes |
|---|---|---|
nginx:1 | No | Tracks the whole major line. Moves on every minor release |
nginx:1.27 | No | Moves on every patch release within the minor |
nginx:1.27.4 | Usually | Rarely re-pointed, but nothing prevents it |
nginx:1.27.4-alpine | Usually | Same, plus a base-image variant that can be rebuilt |
nginx:latest | No | See below |
nginx:stable, nginx:mainline | No | Rolling channel names by design |
nginx@sha256:... | Yes | Content-addressed. Cannot change |
The middle rows deserve a caveat that people skip. Even a fully-qualified patch
tag like 1.27.4-alpine is commonly rebuilt β same application version, new
base image, because the base picked up a security fix. That is the publisher
doing exactly the right thing, and it still means the digest under that tag
changed without the name changing. Most Docker Official Images are rebuilt on a
schedule for precisely this reason.
So βpin to a patch versionβ is a real improvement over latest and is not the
same thing as a pin. Only a digest is a pin.
Why latest is worse than a badly chosen tag
latest is not special to the daemon in any way that means βnewestβ. It is an
ordinary string. It is special in four ways that are all operational.
1. It is the implicit default. docker run nginx means nginx:latest. So
does image: nginx in a Compose file. Nobody chose it; they just did not type a
tag, which means latest ends up in production without a decision ever being
made about it.
2. Compose treats it differently from every other tag. The Compose
specification states plainly: βThe latest tag is always pulled even when the
missing pull policy is used.β missing is the default policy. So a service on
latest re-resolves from the registry on every docker compose up, while a
service on 1.27 uses the cached image. Two services in the same file, two
different caching behaviours, and nothing on the page says so.
3. It has no agreed meaning. Some publishers point it at the newest stable
release. Some point it at the newest build of any kind, including release
candidates. Some point it at whatever their CI last pushed. Some tagged it once
in 2019 and never again, so latest is the oldest image in the repository.
There is no way to tell which, short of reading the publisherβs documentation.
4. It destroys the ability to roll back. This is the one that costs a night.
SHA=$(git rev-parse --short HEAD)
REPO=registry.example.com/myorg/myapp
docker buildx build \
--tag "$REPO:$SHA" \
--tag "$REPO:latest" \
--push \
.
# Record what production should reference. This line is the rollback plan.
crane digest "$REPO:$SHA"Detecting tag drift across a fleet
The point of understanding all this is being able to answer one question during an incident: is every host running the same image?
DRIFT=0
for id in $(docker ps -q); do
name=$(docker inspect --format '{{.Name}}' "$id")
ref=$(docker inspect --format '{{.Config.Image}}' "$id")
bound=$(docker inspect --format '{{.Image}}' "$id")
local_now=$(docker image inspect --format '{{.Id}}' "$ref" 2>/dev/null || echo "absent")
if [ "$bound" != "$local_now" ]; then
echo "DRIFT $name ref=$ref bound=$bound tag-now=$local_now" >&2
DRIFT=1
fi
done
[ "$DRIFT" -eq 0 ] && echo "every running container matches its tag on this host"
exit "$DRIFT"That compares the container to the local image store. To compare the host to the registry β the check that catches βthis host has not pulled since Tuesdayβ β resolve the tag remotely without pulling:
IMAGE=registry.example.com/myorg/myapp:1.4
REMOTE=$(crane digest "$IMAGE")
LOCAL=$(docker image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$IMAGE" \
| sed 's/.*@//')
echo "registry: $REMOTE"
echo "host: $LOCAL"
[ "$REMOTE" = "$LOCAL" ] || echo "this host is running a superseded image" >&2Run that across the fleet and group the results. If it returns more than one distinct digest, you have a fleet running two versions of one name, and every βit reproduces on some nodesβ report you have makes sense.
Choosing a pinning strategy
Pin proportionately. The strongest option is not always worth its friction.
services:
# Dev: mutable, convenient, re-resolved on every up.
scratchpad:
image: nginx:latest
# Most teams, most services: a patch tag plus a recorded digest in
# the change ticket. Readable in review, and you can still audit.
web:
image: nginx:1.27.4
# Anything in the request path, anything handling data, anything
# you would have to explain in an incident review.
payments:
image: registry.example.com/myorg/payments@sha256:0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1
A pure-digest manifest is unreadable in a pull request β nobody can tell
sha256:0c1a... from sha256:0c1b... at a glance. The usual answer is a
comment carrying the human-readable version next to the digest, or a
digest-resolving step in CI that rewrites tags to digests at deploy time. Both
work. What does not work is deciding digests are too awkward and going back to
tags for the payments service.
Knowledge check
Knowledge check Β· 6 questions
Q1. How does Compose treat a service on `image: myapp:latest` differently from one on `image: myapp:1.4`, under the default pull policy?
Q2. Production runs `myapp:latest`. A bad build moves the tag and the deploy breaks. Why is rollback hard?
Q3. Which of these will pick up a moved tag? Select all that apply.
Q4. Pinning to a fully-qualified patch tag such as `nginx:1.27.4-alpine` guarantees the content will not change.
Q5. What does `docker tag myapp:1.0 myapp:stable` cost on the host?
Q6. You want to stop tag drift at source rather than detect it afterwards. What is the strongest available control?
Passing score: 75%. Answers are checked in this browser.