Skip to main content
RunBook Academy

Docker & ContainersIV Β· ImagesTags

Tags β€” mutable references to digests

Foundation⏱ ~26 mindocker

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

Not yet marked complete on this device.

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 styleStable in practice?Notes
nginx:1NoTracks the whole major line. Moves on every minor release
nginx:1.27NoMoves on every patch release within the minor
nginx:1.27.4UsuallyRarely re-pointed, but nothing prevents it
nginx:1.27.4-alpineUsuallySame, plus a base-image variant that can be rebuilt
nginx:latestNoSee below
nginx:stable, nginx:mainlineNoRolling channel names by design
nginx@sha256:...YesContent-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.

Configuration changeCI tagging
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?

Read-only / Safedrift check
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:

Read-only / Saferemote drift
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" >&2

Run 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

  1. Q1. How does Compose treat a service on `image: myapp:latest` differently from one on `image: myapp:1.4`, under the default pull policy?

  2. Q2. Production runs `myapp:latest`. A bad build moves the tag and the deploy breaks. Why is rollback hard?

  3. Q3. Which of these will pick up a moved tag? Select all that apply.

  4. Q4. Pinning to a fully-qualified patch tag such as `nginx:1.27.4-alpine` guarantees the content will not change.

  5. Q5. What does `docker tag myapp:1.0 myapp:stable` cost on the host?

  6. 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.