Pinning Base Image Digests for Multi-Arch Builds
A developer pins FROM node:20-slim@sha256:4b1e… for reproducibility, and the next build on an M-series Mac fails with no match for platform in manifest: not found, or — worse — succeeds but runs an amd64 base under emulation on every laptop. Or nothing is pinned, and node:20-slim resolves to one image on Monday's CI run and a different one on Tuesday's laptop build, so "same Dockerfile" no longer means "same base". Digest pinning is the right idea; pinning the wrong digest breaks multi-architecture builds. This page pins the multi-arch index digest and keeps it current, as part of cross-platform container builds.
The key distinction is between a manifest list (or OCI image index) digest, which covers every platform, and a platform manifest digest, which covers exactly one.
Diagnostic
Look at what a tag resolves to and which digest the Dockerfile pins:
#!/usr/bin/env bash
set -euo pipefail
grep -nE '^FROM ' Dockerfile
docker buildx imagetools inspect node:20-slim --format '{{json .Manifest}}' | jq -r '.mediaType, .digest'
docker buildx imagetools inspect node:20-slim --format '{{json .Manifest.Manifests}}' \
| jq -r '.[] | select(.platform.os=="linux") | "\(.platform.architecture)\t\(.digest)"'
pinned=$(grep -oE 'sha256:[a-f0-9]{64}' Dockerfile | head -1)
docker buildx imagetools inspect "node@$pinned" --format '{{json .Manifest}}' | jq -r '.mediaType'
Expected bad output:
1:FROM node:20-slim@sha256:9a1c3f…d2e7
application/vnd.oci.image.index.v1+json
sha256:5f0e8a…41b9
amd64 sha256:9a1c3f…d2e7
arm64 sha256:c47b20…9f10
application/vnd.oci.image.manifest.v1+json
The tag resolves to an index covering both architectures, but the Dockerfile pins the amd64 platform manifest — copied from docker inspect on an Intel CI runner. On arm64 there is no match.
Root cause
A multi-architecture image is published as an index that points to one manifest per platform. A tag points to the index, and so does the index digest. When a builder resolves FROM image@sha256:<index>, it reads the index and picks the manifest for its target platform. When the pinned digest is a platform manifest instead, there is no index to choose from: the builder gets exactly that platform or fails. Platform digests end up in Dockerfiles because the obvious commands return them — docker inspect --format '{{index .RepoDigests 0}}' after pulling on one machine reports the digest of what that machine pulled, which for a multi-arch image may be the index or the platform manifest depending on the Docker version and image store. Unpinned tags have the opposite problem: they move whenever the publisher pushes, so two builds hours apart can use different bases.
Official images move more often than people expect. node:20-slim is rebuilt whenever Debian publishes security updates or Node ships a patch release, which can be several times a month. Each rebuild changes the index digest even when the Node version in the tag name does not change. Without a pin, a laptop that pulled the image last month and a CI runner that pulled it this morning run different operating-system packages under the same tag — exactly the kind of invisible difference that produces "only fails in CI" reports about TLS, time zones or native modules. With a pin, those updates still arrive, but as a pull request whose diff says precisely what changed and whose CI run proves the new base works on every architecture.
Resolution
- Resolve the index digest explicitly from the registry, never from a local pull:
#!/usr/bin/env bash
set -euo pipefail
image=node:20-slim
digest=$(docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' | jq -r '.digest')
media=$(docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' | jq -r '.mediaType')
case "$media" in
*index*|*manifest.list*) echo "FROM $image@$digest" ;;
*) echo "warning: $image is single-platform ($media)"; echo "FROM $image@$digest" ;;
esac
- Pin tag and digest together so the Dockerfile stays readable and tools can update it:
# syntax=docker/dockerfile:1.7
FROM node:20-slim@sha256:5f0e8a0c7d3b2e6f41a9c8d7e5b4a3f2c1d0e9f8a7b6c5d4e3f2a1b0c9d841b9 AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
The tag is ignored when a digest is present, but it tells readers and update tools which line to follow.
- Build for both platforms to prove the pin works:
#!/usr/bin/env bash
set -euo pipefail
docker buildx build --platform linux/amd64,linux/arm64 -t shop/api:pin-check .
- Automate updates so pins do not freeze security fixes out. Renovate understands
tag@digestpins and opens pull requests when the tag's index digest changes:
{
"extends": ["config:recommended", "docker:pinDigests"],
"packageRules": [
{ "matchDatasources": ["docker"], "matchUpdateTypes": ["digest"], "schedule": ["before 6am on monday"], "automerge": false }
]
}
Dependabot's Docker ecosystem does the same for FROM lines with digests.
Expected output
$ docker buildx imagetools inspect node@sha256:5f0e8a…41b9 --format '{{json .Manifest}}' | jq -r '.mediaType'
application/vnd.oci.image.index.v1+json
$ docker buildx build --platform linux/amd64,linux/arm64 -t shop/api:pin-check .
=> [linux/amd64 base 1/4] FROM docker.io/library/node:20-slim@sha256:5f0e8a…41b9
=> [linux/arm64 base 1/4] FROM docker.io/library/node:20-slim@sha256:5f0e8a…41b9
=> exporting manifest list sha256:0c9d…
Both platforms build from the same pinned index, and every laptop and CI runner now starts from byte-identical base layers for its architecture.
The practical difference shows up in debugging. When a bug appears only in CI, the base image is no longer a suspect: the digest in the build log matches the one in the Dockerfile, which matches what the laptop used. That removes one of the most time-consuming "what's different?" questions in local-versus-CI investigations, and it is also what makes a build log useful months later when reproducing an old release.
Prevention
- Lint Dockerfiles for platform digests. In CI, check that every pinned digest is an index when the image is multi-arch:
#!/usr/bin/env bash
set -euo pipefail
grep -hoE '^FROM [^ ]+@sha256:[a-f0-9]{64}' Dockerfile* | awk '{print $2}' | while read -r ref; do
mt=$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' | jq -r '.mediaType')
case "$mt" in *index*|*list*) echo "ok $ref" ;; *) echo "SINGLE $ref"; exit 1 ;; esac
done
Require digests on every
FROMwith a Hadolint rule or a simple grep, so unpinned tags are caught in review.Pin digests in Compose
image:fields too for third-party services such as Postgres and Redis, using the same index digests.
Platform caveats
Apple Silicon (ARM64): an amd64 platform digest does not always fail — with Rosetta or QEMU enabled, Docker may silently run the amd64 base under emulation. Check
docker image inspect --format '{{.Architecture}}'on the built image.
Docker Desktop containerd image store: with the containerd store enabled,
docker inspectoften reports index digests; with the classic store it reports platform digests. Always resolve from the registry withimagetoolsto avoid depending on the local store.
Private registries:
imagetools inspectneeds registry credentials; run it afterdocker login, and use the same credential helper as builds.
Rollback
Return to tag-only references if pins block urgent work; the build then resolves the current tag as before:
#!/usr/bin/env bash
set -euo pipefail
sed -E -i.bak 's/^(FROM [^@ ]+)@sha256:[a-f0-9]{64}/\1/' Dockerfile
grep -n '^FROM' Dockerfile
Frequently Asked Questions
Why does my pinned image fail with "no match for platform in manifest"?
The digest is a single-platform manifest, not the multi-arch index. Resolve the index digest with docker buildx imagetools inspect <image> --format '{{json .Manifest}}' and pin that instead.
Does pinning digests stop security updates?
Only if nothing updates the pins. Use Renovate or Dependabot to open pull requests when the tag's digest changes, so updates arrive as reviewed changes instead of silently.
Should I keep the tag next to the digest?
Yes. Docker ignores it when a digest is present, but it documents intent and lets update tools know which tag to track.
How do I pin images in Compose files?
Use the same form in image: fields, for example postgres:16.4@sha256:<index digest>. The same index-versus-platform rule applies.