Originally published on docker.learnio.dev — part of the Docker fundamentals course. View this lesson on the original site.
The simple rule
When CI finishes a build, you get a boxed-up app called an image. That box does not change. You copy the same box to staging, then to production.
The mistake teams make: they only write down a friendly name like app:1.4.2 and assume that name always means the same box. It might not. Someone can put a different box behind the same name in the registry.
Same tag, different box (concrete example)
The registry is storage for images. A tag is just a sticky note on one image that says 1.4.2. The registry allows moving that sticky note.
Monday:
- CI builds your app and pushes it.
- The registry stores image A and puts the tag
app:1.4.2on A. - You deploy to production using
app:1.4.2. Production runs A. Good.
Friday:
- Someone fixes a bug, CI builds again, and pushes again.
- The registry stores image B (new bytes) and moves the tag
app:1.4.2from A to B. - The name in Slack still says
1.4.2, but it is not the same build anymore.
Saturday:
- A new server runs
docker pull app:1.4.2. It gets B, not A. - Your release notes still say
1.4.2, so it looks like “the same release,” but production from Monday and the new server from Saturday are different apps.
Nothing lied about the spelling 1.4.2. The tag was reused. That is why a tag alone is a weak record of what ran.
A digest is different. Image A might be app@sha256:aaa… and image B might be app@sha256:bbb…. Those fingerprints cannot swap places. If you wrote down aaa on Monday, you can always pull A again, even if 1.4.2 now points at B.
So you also write down the image fingerprint. In Docker that fingerprint is called a digest. It looks like:
ghcr.io/your-org/app@sha256:abc123def456...
If staging and production both use that full string, they run the same bits. The short tag is for humans. The digest is the proof.
Tag vs digest (plain English)
| What you see | What it is | Good for |
|---|---|---|
app:1.4.2 |
Tag — a nickname | README, chat, Compose files |
app@sha256:abc123… |
Digest — a fingerprint | “This exact build went to prod” |
Think of a tag like a label on a shelf. Think of a digest like a serial number on the product. Two shelves can have the same label on different products. The serial number does not lie.
{
"ariaLabel": "App image with digest fingerprint",
"segments": [
{ "text": "ghcr.io/your-org/app", "bottomLabel": { "text": "Where it lives" }, "wide": true },
{ "text": "@sha256:abc123...", "topLabel": { "text": "Fingerprint", "variant": "digest" }, "highlight": "digest" }
]
}
What “promote” means here
Promote just means: this build is allowed to go to the next environment.
A sane flow:
flowchart LR
A["CI builds the image once"] --> B["Push to registry"]
B --> C["Save the digest in the release notes"]
C --> D["Staging pulls that digest"]
D --> E["Production pulls the same digest"]
Do not rebuild on the server “to be safe.” Do not docker exec into the container to fix a file and call it deployed. Build a new image instead.
Pin the base image in your Dockerfile
Your Dockerfile starts with FROM. That line picks the starting box (Alpine, Debian, PHP image, etc.).
If you only write:
FROM alpine:3.20
you get “whatever 3.20 means today” when someone builds next month. To lock it down, add the fingerprint:
FROM alpine:3.20@sha256:PASTE_THE_REAL_DIGEST_HERE
{
"ariaLabel": "FROM with tag plus digest pin",
"segments": [
{ "text": "FROM", "topLabel": { "text": "Instruction", "variant": "command" }, "highlight": "command" },
{ "text": "alpine:3.20", "bottomLabel": { "text": "Nickname" } },
{ "text": "@sha256:...", "topLabel": { "text": "Fingerprint", "variant": "digest" }, "highlight": "digest" }
]
}
How to get the digest
After a pull on your machine:
docker pull alpine:3.20
docker image inspect --format '{{index .RepoDigests 0}}' alpine:3.20
You should see one line like alpine@sha256:…. Copy that hash into the Dockerfile.
Without pulling first (registry lookup):
docker buildx imagetools inspect alpine:3.20 --format '{{json .Manifest}}' | head -c 400
Use a digest from a source you trust. Paste the real value — do not leave the placeholder in a real project.
Your app image works the same way
After CI pushes your app, save the digest CI printed. Put it in:
- the deploy config, or
- a runbook, or
- the ticket that says “what went to production”
Example:
ghcr.io/your-org/app@sha256:abc123def456...
Staging and production should both point at that string (or the same hash in your orchestrator’s UI). The tag 1.4.2 can still be in Slack for humans. The digest is what makes “same build” true.
Rollback in basic terms
Rollback means: run yesterday’s fingerprint, not yesterday’s nickname.
If you only saved app:1.4.2, the registry might have moved that name to a newer (bad) build. If you saved app@sha256:good123…, you can pull that exact box again.
Things that undo the whole idea
- Editing files inside a running container with
docker exec— gone on the next restart, not in the image - Deploying
:latestand not writing down which digest ran - Building again on the server instead of using the image CI already built
Those habits bring back “works on my machine” and “prod is different.”
Why pinning feels annoying
When you pin a digest, the base image does not change under you until you change the pin on purpose. Security updates need a new digest, a new build, and a new test pass.
That extra step is intentional. It stops silent surprises.
Check what is running
On a server, find the container name or ID, then:
docker inspect --format '{{.Image}}' CONTAINER_NAME_OR_ID
docker image inspect --format '{{.RepoDigests}}' IMAGE_ID
Compare that digest to what CI saved. No match means you are not running the build you think you are.
GitHub example
Academy repo: 2-images-layers/tags-vs-digests
Try it yourself
- Run
docker pull alpine:3.20. - Run
docker image inspect --format '{{index .RepoDigests 0}}' alpine:3.20and copy thesha256line. - In a scratch Dockerfile, set
FROM alpine:3.20@sha256:…with that hash. - In one sentence, say why
app:1.4.2alone is not enough for production. - In one sentence, say what you would save from CI after a good deploy.
You are done when you can explain tag vs digest to a teammate without saying “manifest” unless they ask.
From Docker fundamentals — Images, layers, tags, and everyday commands