Pin Docker Base Images by Digest, Not by Tag
Resolve mutable tags to immutable digests, record them in the Dockerfile, and verify every rebuild starts from the exact same bytes.
Task
Convert a floating base-image tag into a pinned digest and prove builds are reproducible despite upstream changes.
When to use this
A Tuesday rebuild of an untouched Dockerfile fails: the latest tag upstream gained a breaking package version overnight, and nobody changed a single line of code.
Prerequisites
- Docker or Podman with registry access
- A Dockerfile FROM a tagged base image
- A CI job that rebuilds images on schedule
Steps
1. Resolve the current digest behind the mutable tag.
docker manifest inspect node:22-alpine | jq -r '.manifests[0].digest'Expected output: A digest such as sha256:4f47c... prints, which is the immutable identity of the image the tag pointed to at this moment.
2. Record the digest in the Dockerfile with a human-readable comment.
# Dockerfile
# base: node:22-alpine resolved 2026-10-22 (upstream tag was updated 2h ago)
FROM node:22-alpine@sha256:4f47cbe7a1c2...
RUN npm ci --omit=dev && npm run build
USER node
CMD ["node", "dist/server.js"]
Expected output: Builds now use the pinned digest; a comment records which tag it came from so future upgrades are a deliberate act, not an accident.
3. Rebuild twice and compare the base-layer digests.
docker build -t app:pinned1 . && docker build -t app:pinned2 . && docker inspect app:pinned1 --format "{{.RootFS.Layers}}" | sha256sum && docker inspect app:pinned2 --format "{{.RootFS.Layers}}" | sha256sumExpected output: Both builds report the same layer hash sequence, proving the base image bytes are identical across rebuilds.
4. Test the upgrade path deliberately when you want the new base.
docker manifest inspect node:22-alpine | jq -r '.manifests[0].digest' && docker build --build-arg BASE_DIGEST=sha256:NEW... -t app:base-upgrade .Expected output: A candidate digest prints and a test build runs against it in isolation, letting CI validate the new base before the pin moves.
5. Add a CI check that flags digest drift in the tag.
# .github/workflows/base-drift.yml
- name: Detect base tag drift
run: |
pinned=$(grep -oP "FROM node:22-alpine@\Ksha256:[a-f0-9]+" Dockerfile)
current=$(docker manifest inspect node:22-alpine | jq -r ".manifests[0].digest")
if [ "$pinned" != "$current" ]; then
echo "::warning::base tag moved: pinned=$pinned current=$current"
fi
Expected output: A scheduled job warns when the upstream tag no longer matches the pin, converting silent drift into a reviewable PR.
Troubleshooting
Problem
Build fails with manifest unknown after pinning
Solution
The digest belongs to a multi-arch index but the build targets one platform; resolve the per-arch digest with jq by matching platform, or pull with the index digest on a matching runner.
Problem
docker manifest inspect needs authentication
Solution
Private registries require docker login first, or a registry token for read-only CI contexts; fetch the token with a curl to the registry auth endpoint.
Problem
Team finds digests unreadable in reviews
Solution
Keep the tag alongside the digest (FROM node:22-alpine@sha256:...) plus the resolution-date comment, and let the CI drift check be the enforcement layer instead of eyeballs.
Limitations
- Digest pinning freezes known-good bytes but also freezes security fixes; pair it with a scheduled drift check so upgrades stay deliberate.
- Reproducibility covers the base layers only; your own layers still vary with package registries unless lockfiles are pinned too.
A tag is a pointer that moves; a digest is the bytes
node:22-alpine is a promise that changes underneath you: the upstream can republish the tag with new packages at any hour. Pinning by digest freezes the exact image bytes your build consumed, which is the difference between a rebuild being a no-op and being a lottery.
# Before: floating, breaks overnight
FROM node:22-alpine
# After: immutable, with provenance comment
# base: node:22-alpine resolved 2026-10-22
FROM node:22-alpine@sha256:4f47cbe7a1c2...
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/server.js"]
# Verify twice-built layer identity
# docker inspect app:v1 --format "{{.RootFS.Layers}}" | sha256sum
# docker inspect app:v2 --format "{{.RootFS.Layers}}" | sha256sum
# -> identical
Freeze deliberately, drift loudly
Pinning is not set-and-forget: security fixes also arrive via the moving tag. The scheduled drift check compares your pin against the live tag and raises a warning, so you upgrade on your schedule with CI validation, instead of inheriting upstream surprises.
Readability and enforcement are different jobs
Humans read tags; machines enforce digests. Keeping both in the FROM line with a resolution-date comment gives reviewers context, while the CI check does the actual guarding. Asking humans to eyeball 64 hex characters reliably is a losing game.