Skip to main content
RunBook Academy

Docker & ContainersIV Β· ImagesMulti-platform

Multi-platform images

Intermediate⏱ ~28 mindockerdocker buildx

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

Not yet marked complete on this device.

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.

Read-only / Safeno match
$ docker pull --platform linux/arm64 myorg/myapp:1.0.0
no matching manifest for linux/arm64 in the manifest list entries

Illustrative 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:

Read-only / Safewhat does it have?
$ docker buildx imagetools inspect myorg/myapp:1.0.0
Name:      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-manifest

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

Configuration changenative nodes
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 --bootstrap

This 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

Configuration changebuilder
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 ls
Service impact possiblebuild and push
REPO=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

Read-only / Safeassert platforms
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:

Read-only / Safeend to end
$ docker image inspect myorg/myapp:1.0.0 --format '{{.Architecture}}/{{.Os}}' && docker run --rm myorg/myapp:1.0.0 uname -m
arm64/linux
aarch64

Illustrative output

Knowledge check

Knowledge check Β· 6 questions

  1. Q1. Who decides which platform entry a pull uses?

  2. Q2. A Dockerfile starts `FROM golang:1.24-alpine AS build` and the pipeline builds for linux/arm64 on an amd64 runner. What happens?

  3. Q3. Which are documented strategies for producing a multi-platform image? Select all that apply.

  4. Q4. A cache hit on the amd64 half of a multi-platform build means the arm64 half will also be fast.

  5. Q5. On a stock Docker Engine 28.x host, why does `docker buildx build --platform linux/amd64,linux/arm64 --load .` fail?

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