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.

Index Digest vs Platform Digest Layers of an image reference from tag to index to per-platform manifests. Index Digest vs Platform Digest tag node:20-slim moves over time index digest covers all platforms amd64 manifest one platform only arm64 manifest one platform only
Pin the index digest; each builder then selects its own platform manifest beneath it.

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

  1. 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
  1. 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.

  1. 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 .
  1. Automate updates so pins do not freeze security fixes out. Renovate understands tag@digest pins 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.

Resolving and Maintaining a Pin Flow from resolving the index digest to building both platforms and receiving automated update PRs. Resolving and Maintaining a Pin resolve index imagetools pin tag@digest Dockerfile build both archs buildx Renovate PRs weekly digests
The pin is only as good as the process that refreshes it.

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

  1. 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
  1. Require digests on every FROM with a Hadolint rule or a simple grep, so unpinned tags are caught in review.

  2. Pin digests in Compose image: fields too for third-party services such as Postgres and Redis, using the same index digests.

Platform Digest vs Index Digest Comparison of pinning a single-platform manifest digest against pinning the multi-arch index digest. Platform Digest vs Index Digest platform digest index digest reproducible reproducible one architecture only every architecture fails on other platforms each builder picks its own from local docker inspect from imagetools inspect
Both are reproducible, but only the index digest works on every architecture.

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 inspect often reports index digests; with the classic store it reports platform digests. Always resolve from the registry with imagetools to avoid depending on the local store.

Private registries: imagetools inspect needs registry credentials; run it after docker 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.