Skip to content
Docker
Lab 7 of 11·35mIntermediate

Tags lie — pull by digest instead

Run your own registry, push two different images under the same tag, and see why :latest is not a version.

You need

  • Docker Engine 24+ with internet access

Do first

A tag is a mutable pointer. Two machines pulling myapp:v1.2 an hour apart can get different images and both be correct. This lab demonstrates that on a registry you control, then fixes it.

1. Run a registry locally

mkdir -p ~/labs/docker-registry && cd ~/labs/docker-registry
docker run -d -p 5000:5000 --name labreg registry:2
sleep 2
curl -s http://localhost:5000/v2/_catalog

A real OCI registry in one container, storing to its own volume. Nothing leaves your machine.

Verify

curl -s http://localhost:5000/v2/_catalog # {"repositories":[]}

2. Push something under a tag

cat > Dockerfile <<'DOCKER'
FROM alpine:3.20
RUN echo "version one" > /version
CMD ["cat", "/version"]
DOCKER
docker build -q -t localhost:5000/labapp:v1 .
docker push -q localhost:5000/labapp:v1
docker run --rm localhost:5000/labapp:v1

Verify

docker run --rm localhost:5000/labapp:v1 # version one

3. Now change the image and reuse the tag

docker image inspect localhost:5000/labapp:v1 \
  --format 'digest before: {{index .RepoDigests 0}}'
cat > Dockerfile <<'DOCKER'
FROM alpine:3.20
RUN echo "version two — SAME TAG" > /version
CMD ["cat", "/version"]
DOCKER
docker build -q -t localhost:5000/labapp:v1 .
docker push -q localhost:5000/labapp:v1
docker image inspect localhost:5000/labapp:v1 \
  --format 'digest after:  {{index .RepoDigests 0}}'

Same tag, different digest. Anyone who pulls labapp:v1 now gets different content than they did five minutes ago, with no error and no indication anything changed.

This is not a registry bug — it is what tags are. It is also how a rollback to v1.2 deploys something that is not what v1.2 was when you tested it.

Verify

docker pull -q localhost:5000/labapp:v1 >/dev/null docker run --rm localhost:5000/labapp:v1 # version two — SAME TAG

4. Pull by digest, which cannot change

DIGEST=$(docker image inspect localhost:5000/labapp:v1 \
  --format '{{index .RepoDigests 0}}')
echo "$DIGEST"
docker run --rm "$DIGEST"

A digest is the SHA-256 of the image manifest — content-addressed, so it names exactly one image forever. Push whatever you like to the tag; the digest still resolves to the bytes you tested.

That is what belongs in a deployment manifest:

image: registry.example.com/labapp@sha256:abc123...

Kubernetes, Compose and Terraform all accept the @sha256: form. Using it is the difference between a reproducible deploy and a hopeful one.

Verify

docker image inspect localhost:5000/labapp:v1 --format '{{index .RepoDigests 0}}' \ | grep -c "@sha256:" # 1

5. Why :latest is worse than no tag

docker build -q -t localhost:5000/labapp:latest .
docker push -q localhost:5000/labapp:latest
curl -s http://localhost:5000/v2/labapp/tags/list

latest is not a special tag. It is the default when you omit one, it means nothing, and it is not automatically the newest thing pushed — it is whatever was last tagged latest, which might be older than v2.

Three consequences worth stating plainly:

  • FROM node:latest in a Dockerfile makes your build non-reproducible. Pin a version.
  • image: myapp:latest in a manifest gives you no idea what is running.
  • With imagePullPolicy: IfNotPresent, a node that already has _a_ latest will never fetch yours.

Verify

curl -s http://localhost:5000/v2/labapp/tags/list | grep -c latest # 1

6. Tag deliberately

GITSHA=$(echo "pretend-commit-sha" | sha1sum | cut -c1-7)
docker build -q -t "localhost:5000/labapp:1.2.3" \
             -t "localhost:5000/labapp:1.2" \
             -t "localhost:5000/labapp:sha-$GITSHA" .
docker push -q --all-tags localhost:5000/labapp
curl -s http://localhost:5000/v2/labapp/tags/list

The scheme that works: an immutable tag nobody ever reuses (the commit SHA, or a full semver), plus moving tags for human convenience (1.2, latest). Deploy the immutable one; let people read the moving ones.

Verify

curl -s http://localhost:5000/v2/labapp/tags/list | grep -c "sha-" # 1

7. Inspect a remote image without pulling it

docker manifest inspect localhost:5000/labapp:v1 | head -n 12
docker manifest inspect alpine:3.20 | grep -E "architecture|os\"" | head -n 6

docker manifest inspect reads the manifest over the API without downloading layers. On a public image it shows the platforms available — which is how you find out that the image failing on your ARM laptop simply has no arm64 build.

Verify

docker manifest inspect alpine:3.20 | grep -c "architecture" # several — one per platform in the manifest list

Clean up

docker rm -f labreg
docker image rm localhost:5000/labapp:v1 localhost:5000/labapp:latest \
  localhost:5000/labapp:1.2.3 localhost:5000/labapp:1.2 2>/dev/null
cd ~ && rm -rf ~/labs/docker-registry

Where this goes next

You can pull exactly what you tested. Next: declaring a whole stack instead of remembering eight docker run flags.