Docker & ContainersIV Β· ImagesMulti-platform
Multi-platform images
What you'll learn
- Explain how a client selects a platform entry from an image index
- Choose between emulation, native nodes and cross-compilation for a given build
- Build a multi-platform image with the right builder driver, and know why the default one fails
- Diagnose `no matching manifest` and `exec format error` correctly
- Verify an image really has the platforms you think it has
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
A multi-platform image is one image index pointing at several per-platform
manifests. docker pull nginx on an amd64 host and on an arm64 host fetch
different bytes under the same name, and neither of them is wrong.
The mechanism is simple. Almost every problem people have with it comes from one of two misunderstandings: which side chooses the platform, and which builder driver can produce an index in the first place.
The image index
flowchart TB
L["image index<br/>application/vnd.oci.image.index.v1+json"]
L --> A["manifest β platform linux/amd64"]
L --> B["manifest β platform linux/arm64"]
L --> C["manifest β platform linux/arm/v7"]
L --> D["manifest β unknown/unknown<br/>attestation (SBOM, provenance)"]
A --> AL["amd64 layer blobs"]
B --> BL["arm64 layer blobs"]
Each entry carries a platform object. The OCI specification requires
architecture and os, and allows os.version, os.features and variant.
variant is what separates linux/arm/v7 from linux/arm/v6 β two entries
with identical architecture and os that are not interchangeable.
The layers are entirely separate blobs. There is no shared βfat binaryβ: an arm64 image and an amd64 image of the same application share nothing but the index that names them both.
The client picks the platform, not the registry
This is the fact that turns most multi-platform incidents from a twenty-minute investigation into a one-command answer.
The registry serves the index. The client walks the manifests array,
compares each platform object against its own OS, architecture and variant,
picks the match, and requests that manifest by digest. The registry never sees
your architecture and never makes a choice.
$ docker pull --platform linux/arm64 myorg/myapp:1.0.0no matching manifest for linux/arm64 in the manifest list entriesIllustrative output
There is no HTTP error here, no 404, and no authentication problem β the registry answered correctly with the index it holds. The image simply was not built for arm64. Chasing this as a network, credentials or registry-config problem is the standard wrong turn, and it is expensive because all three of those are plausible-sounding and slow to rule out.
The command that ends it takes two seconds and pulls nothing:
$ docker buildx imagetools inspect myorg/myapp:1.0.0Name: registry.example.com/myorg/myapp:1.0.0
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1
Manifests:
Name: registry.example.com/myorg/myapp:1.0.0@sha256:4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1e0c1a2f9b7d3c
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Name: registry.example.com/myorg/myapp:1.0.0@sha256:f21aabd54d6a48d8b9d3b8e5b1e0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: unknown/unknown
Annotations:
vnd.docker.reference.type: attestation-manifestIllustrative output
One real platform, one attestation manifest. The build produced amd64 only,
whatever the CI jobβs --platform argument said β usually because the argument
was on the wrong command, or the build fell back to a builder that cannot do
multi-platform.
Three ways to build for another architecture
The documentation names three strategies, and the right one depends on what your build actually spends its time doing.
1. Emulation with QEMU
Simplest to set up, slowest to run. The documentation is blunt about the cost: βEmulation with QEMU can be much slower than native builds, especially for compute-heavy tasks like compilation and compression or decompression.β
That βespeciallyβ is the decision rule. A Python or Node image, where the build is mostly unpacking and copying, is fine under emulation. A Go, Rust or C++ build, where the build is compilation, can go from ninety seconds to forty minutes.
2. Multiple native nodes
Attach a real arm64 machine to the builder as a second node. Each platform is built on hardware that runs it natively, and buildx assembles the index.
docker buildx create --name multiarch \
--driver docker-container \
--platform linux/amd64 \
--node local-amd64 --use
docker buildx create --name multiarch --append \
--driver docker-container \
--platform linux/arm64 \
--node remote-arm64 \
ssh://builder@arm-builder.example.com
docker buildx inspect multiarch --bootstrapThis is what most serious pipelines end up doing, because a cloud arm64 instance is cheap and forty minutes of emulated compilation per build is not.
3. Cross-compilation in a multi-stage build
The best option when the toolchain supports it, because the build stage runs natively and only the tiny runtime stage is architecture-specific.
BuildKit provides automatic build arguments for exactly this. BUILDPLATFORM is
the platform the builder is running on; TARGETOS, TARGETARCH and
TARGETVARIANT describe the platform being produced.
# The build stage pins itself to the BUILDER's platform, so it never emulates.
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
COPY . .
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /out/server .
# The runtime stage has no --platform, so it is built FOR the target.
FROM alpine:3.21
COPY --from=build /out/server /server
ENTRYPOINT ["/server"]
The --platform=$BUILDPLATFORM on the first FROM is the whole trick, and
omitting it is the commonest mistake. Without it, the Go toolchain image itself
is pulled for the target architecture and runs under emulation, which is
precisely what you were trying to avoid β and the build still succeeds, just
slowly, so nothing tells you.
ARG TARGETOS and ARG TARGETARCH must be declared inside the stage that uses
them. They are provided by BuildKit; you do not pass them with --build-arg.
Building it
docker buildx create --name multiarch --driver docker-container --use
docker buildx inspect --bootstrap
# Confirm which driver is active before you rely on it.
docker buildx lsREPO=registry.example.com/myorg/myapp
TAG=1.0.0
docker buildx build \
--platform linux/amd64,linux/arm64,linux/arm/v7 \
--tag "$REPO:$TAG" \
--push \
.--push is not a convenience here. It is close to a requirement.
Verification that can fail
IMAGE=registry.example.com/myorg/myapp:1.0.0
WANT="linux/amd64 linux/arm64 linux/arm/v7"
GOT=$(docker buildx imagetools inspect "$IMAGE" --raw \
| jq -r '.manifests[].platform | select(.os != "unknown") | .os + "/" + .architecture + (if .variant then "/" + .variant else "" end)' \
| sort | tr '\n' ' ')
EXPECT=$(echo "$WANT" | tr ' ' '\n' | sort | tr '\n' ' ')
if [ "$GOT" != "$EXPECT" ]; then
echo "platform mismatch: got [$GOT] expected [$EXPECT]" >&2
exit 1
fi
echo "index contains exactly the intended platforms"The select(.os != "unknown") filter is what stops the attestation manifests
from making the count wrong, and it is the reason a naive check passes when it
should not.
The end-to-end proof, on a host of the target architecture, is that the binary actually runs:
$ docker image inspect myorg/myapp:1.0.0 --format '{{.Architecture}}/{{.Os}}' && docker run --rm myorg/myapp:1.0.0 uname -marm64/linux
aarch64Illustrative output
Knowledge check
Knowledge check Β· 6 questions
Q1. Who decides which platform entry a pull uses?
Q2. A Dockerfile starts `FROM golang:1.24-alpine AS build` and the pipeline builds for linux/arm64 on an amd64 runner. What happens?
Q3. Which are documented strategies for producing a multi-platform image? Select all that apply.
Q4. A cache hit on the amd64 half of a multi-platform build means the arm64 half will also be fast.
Q5. On a stock Docker Engine 28.x host, why does `docker buildx build --platform linux/amd64,linux/arm64 --load .` fail?
Q6. Your CI script counts entries in the image index to assert that three platforms were built, and gets four. What is the extra entry?
Passing score: 75%. Answers are checked in this browser.