Why this matters
Two copies of the same app, started from "the same" image name, behave differently. Nothing in your config changed. The cause is almost always that an image name is a movable label, not the image itself. This lesson teaches you to read image names and to pin the one thing that cannot move.
What you need to know already: the previous lesson (layers and config), JSON (Ch 7), hashes like sha256 in passing (sha256sum prints a fingerprint of a file's content: same content, same hash).
Where images live: registries
Images are stored in a registry, a web server for images. You pull (download) images from it and push (upload) yours to it. Docker Hub (docker.io) is the public default; companies run private ones.
Anatomy of an image reference
registry.lab/team/orders:2.14.1@sha256:5f2c...
└── registry ┘└ repository ┘└ tag ┘└─ digest ─┘
- registry - the host that stores it. Defaults to
docker.io(Docker Hub). - repository - the image's name inside the registry: all versions of one image.
nginxmeansdocker.io/library/nginx;library/holds Docker Hub's official images. - tag - a version label, defaults to
latest.nginxandnginx:latestare the same thing. - digest -
sha256:plus the hash of the image's manifest (the small JSON file that lists the image's config and layers). If present, it wins over the tag.
The registry part is recognised by a dot, a colon (a port, Ch 9) or localhost in the first path component. That is why myteam/app is on Docker Hub but registry.lab/app is not.
Pulling, and reading what came down
$ docker pull nginx:1.27
1.27: Pulling from library/nginx
7a1c2f9b0d34: Pull complete
e0f3a1b2c4d5: Pull complete
Digest: sha256:e53039b8572d5bf098282edf0ae574e9a99a3cafd82d7f5b5fd7a3c4e91caf66
Status: Downloaded newer image for nginx:1.27
docker.io/library/nginx:1.27
One Pull complete line per layer. Digest: is the image's digest. Pull it again and nothing downloads:
$ docker pull nginx:1.27
1.27: Pulling from library/nginx
Digest: sha256:8f2c7d1e...
Status: Image is up to date for nginx:1.27
docker images lists what is on this machine:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
maven 3.9-eclipse-temurin-21 38e31ad7d198 3 weeks ago 558MB
eclipse-temurin 21-jdk c3dd73f22330 3 weeks ago 489MB
eclipse-temurin 21-jre 6edd73fb109a 3 weeks ago 271MB
node 22 a2c07ee9c2a3 3 weeks ago 1.1GB
node 22-slim 085ddfb5e4ee 3 weeks ago 226MB
alpine 3.20 5f53374e5deb 3 weeks ago 8.3MB
(These are real images you will use: eclipse-temurin is Java, maven is a Java build tool, node is Node.js - the lessons explain each when you need it.) Read the columns carefully:
- REPOSITORY and TAG - the name, split at the colon.
- IMAGE ID - a local ID (a hash of the image's config), not the registry digest. Two different tags with the same IMAGE ID are one image.
- CREATED - when the image was built, not when you pulled it. A "3 weeks ago" base image is normal; a "2 years ago" one has two years of unfixed security bugs.
- SIZE - the unpacked size on disk, in decimal MB. The registry stores and sends the layers compressed, typically a third of this. Layers shared between images are counted in every image's SIZE but stored once -
docker system df -v(-v: verbose) shows shared vs unique.
--digests adds the digest column:
$ docker images --digests nginx
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
nginx 1.27 sha256:e53039b8572d5bf098282edf0ae574e9a99a3cafd82d7f5b5fd7a3c4e91caf66 3b1e8a9f0c2d 3 weeks ago 192MB
Tags are mutable, digests are not
A tag is a pointer in the registry. Anyone allowed to push can move it:
Monday orders:2.14 -> sha256:aaa...
Tuesday the build server pushes a hotfix as orders:2.14 again
orders:2.14 -> sha256:bbb...
A server that pulled on Monday runs aaa; one that pulls on Tuesday runs bbb. Same tag, different bytes, and nothing in your deploy config changed. A digest is the hash of the content itself: orders@sha256:aaa... can only ever mean those exact bytes. Change one byte and the hash changes.
$ docker inspect -f '{{index .RepoDigests 0}}' nginx:1.27
nginx@sha256:e53039b8572d5bf098282edf0ae574e9a99a3cafd82d7f5b5fd7a3c4e91caf66
$ docker pull nginx@sha256:e53039b8572d5bf098282edf0ae574e9a99a3cafd82d7f5b5fd7a3c4e91caf66
{{index .RepoDigests 0}} means "the first entry of the RepoDigests list" - the name@digest form you can pull or pin.
Why :latest is a production incident waiting to happen
latest is not special. It does not mean "newest"; it means "the tag you get when you did not type one". Whatever was pushed last with no tag, or pushed as latest on purpose, is latest.
What goes wrong in practice:
- a rollback "to the previous version" redeploys
latest, which is the broken one - two servers run different code under the same tag, and the bug only happens on one
- a server set to "always pull on start" silently upgrades the app on a routine restart
- nobody can answer "what exactly is running in prod?" from the config
The rule: humans use tags, deployments use tags that never move (2.14.1, or the git commit id), and anything security-critical pins the digest. The registry lesson at the end of this chapter builds a real tagging strategy.
Pinning a base image in a Dockerfile
A Dockerfile is the recipe for building an image (next lesson). Its first line names the image you build on top of, the base image:
FROM eclipse-temurin:21-jre@sha256:0c7f714bf6df7d9c70526654c35b47...
The tag is kept for humans; the digest is what the builder uses. Now a rebuild next month produces the same base, byte for byte. Tools like Renovate or Dependabot (bots that watch your dependencies and open a pull request when a new version appears) can propose the new digest, so you still get security fixes, deliberately.
The cost is real: a pinned digest never picks up security fixes by itself. Pin and automate the bump.
inspect: the whole config
$ docker image inspect nginx:1.27 --format '{{json .Config}}'
{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","NGINX_VERSION=1.27.2"],"Entrypoint":["/docker-entrypoint.sh"],"Cmd":["nginx","-g","daemon off;"],"User":"","WorkingDir":"","ExposedPorts":{"80/tcp":{}},"Labels":{},"Volumes":null,"StopSignal":"SIGQUIT"}
--format is the long spelling of -f; {{json .Config}} prints the config section as JSON. That is everything a container of this image starts with: the entrypoint and cmd (together, the program to run - a later lesson), the user (empty = root), the environment, the ports, the stop signal. The templates worth memorising:
{{.Config.User}} who it runs as ("" = root)
{{json .Config.Entrypoint}} the executable, as JSON
{{.Architecture}} arm64 / amd64
{{index .RepoDigests 0}} the digest to pin
{{range .RootFS.Layers}}{{println .}}{{end}} one layer per line
{{.Size}} bytes
.Architecture deserves a habit: on an Apple Silicon Mac or this arm64 VM, check it before you push anything that will run on amd64 servers.
What you can now do
- Split any image reference into registry, repository, tag and digest.
- Read
docker imagesand find an image's digest. - Explain why deploying
latestis dangerous and what to pin instead.