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/_catalogA real OCI registry in one container, storing to its own volume. Nothing leaves your machine.
Verify
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:v1Verify
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
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
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/listlatest 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:latestin a Dockerfile makes your build non-reproducible. Pin a version.image: myapp:latestin a manifest gives you no idea what is running.- With
imagePullPolicy: IfNotPresent, a node that already has _a_latestwill never fetch yours.
Verify
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/listThe 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
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 6docker 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
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-registryWhere this goes next
You can pull exactly what you tested. Next: declaring a whole stack instead of remembering eight docker run flags.