The core idea
A tag is a human-friendly pointer, like a git branch: notes-api:1.0.0 points at an image, and it can be moved to
point at a different one. A digest is the image's content hash, like a git commit SHA: it can only ever mean one
exact image. You push and pull by tag for convenience, and deploy by digest when you need certainty.
Push to a registry
Pushing works the same for Docker Hub, GHCR or a private registry: tag the image with the registry's address, then
push. To try it without an account, run a registry locally (the official registry image):
$ docker run -d --name registry -p 5050:5000 registry:2$ docker tag notes-api localhost:5050/notes-api:1.0.0$ docker push localhost:5050/notes-api:1.0.0The push refers to repository [localhost:5050/notes-api]b902110041f8: Pushed3919ea552a8c: Pushed467306870dd0: Pushed...1.0.0: digest: sha256:ce278236dbb7cebf92d1289392cfdca2e8463f61c63cee64112d28ba527c1569 size: 856
docker tag doesn't copy anything; it adds another name for the same image. The push uploads each layer the registry
doesn't already have, then reports the digest. Push the same image again and every layer is skipped.
For GitHub's registry, the only differences are the name and logging in first:
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
docker tag notes-api ghcr.io/YOUR_USERNAME/notes-api:1.0.0
docker push ghcr.io/YOUR_USERNAME/notes-api:1.0.0The token needs the write:packages scope. You'll automate this from CI in 07.06 Project: ship with GitHub Actions.
Tags move
Nothing stops anyone with push access from pushing a different image under an existing tag. Official images do it on
purpose: node:22-alpine is re-pushed for every Node 22 patch and every Alpine security fix. So the same FROM line
can give you a different base next week.
You can find an image's digest with docker image inspect:
$ docker image inspect --format '{{json .RepoDigests}}' node:22-alpine["node@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914"]
Your digest will differ: this one is from the day it was measured, which is exactly the point.
One tag, many platforms
How does node:22-alpine work on both an Intel server and an Apple Silicon laptop? The tag doesn't point at one image.
It points at an index (also called a manifest list) that lists one image per platform:
$ docker buildx imagetools inspect node:22-alpineName: docker.io/library/node:22-alpineMediaType: application/vnd.oci.image.index.v1+jsonDigest: sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914Manifests:Name: docker.io/library/node:22-alpine@sha256:025b8e975a61…Platform: linux/amd64...
Output trimmed. That index also lists linux/arm64, linux/arm/v6, linux/arm/v7 and linux/s390x. When you pull,
Docker picks the one that matches your machine. An image you build on an M-series Mac is linux/arm64 only, and
won't run on a typical amd64 server without emulation. Building for both is 07.04 Multi-platform builds.
A tagging scheme that works
| Tag | Use |
|---|---|
1.4.2 | an exact release; never re-push it |
1.4, 1 | move to the latest patch or minor; convenient for consumers |
sha-3f9a1c2 | the git commit it was built from; great for tracing a deploy back to code |
latest | at most a convenience for humans; don't deploy it |
Production runs ghcr.io/acme/api:1.4. A teammate pushes a broken build as 1.4 by mistake. A server restarts and pulls. What runs?
Start the local registry, push the notes API as 1.0.0, and note the digest. Change a response in server.ts, rebuild, and push it as 1.0.0 again. Pull localhost:5050/notes-api:1.0.0 and check which version you get. Then pull the first version back using its digest.
STUCK? ▸
connection refused on push: the local registry container isn't running, or it's on a different port. docker ps should show registry with 0.0.0.0:5050->5000/tcp.
Recap
Interview questions
What's the difference between an image tag and an image digest?
Your Mac-built image fails on the production server with 'exec format error'. What happened?