Building Multi-Arch Images Locally with docker buildx
A docker buildx build --platform linux/amd64,linux/arm64 invocation aborts with ERROR: Multiple platforms feature is currently not supported for docker driver because the default builder cannot emit a multi-architecture manifest. This page walks through creating a docker-container buildx builder, wiring up QEMU emulation, and producing one image that serves both Intel and Apple Silicon teammates from a single machine; it is part of the cross-platform container build patterns within the wider containerized local environment patterns. If your rebuilds are also slow, pair this with optimizing Docker Compose for fast local rebuilds so the emulated arch does not double your feedback loop.
Diagnostic
The symptom is exact and reproducible: a build that succeeds for a single platform fails the moment you ask for two. Start by confirming which builder is active, because the default docker driver is the one that cannot do this. docker buildx ls lists every builder and marks the current one with an asterisk:
#!/usr/bin/env bash
set -euo pipefail
docker buildx ls
docker buildx inspect --bootstrap | grep -E 'Driver|Platforms'
On a stock Docker Desktop or Engine install you will see the built-in builder using the docker driver, and the Platforms line reports only your host architecture plus whatever the host can natively run. Now reproduce the failure directly — request two platforms against that default builder:
#!/usr/bin/env bash
set -euo pipefail
docker buildx build --platform linux/amd64,linux/arm64 -t demo:multi .
The BAD output is unambiguous and is the error this page resolves:
[+] Building 0.0s (0/0)
ERROR: Multiple platforms feature is currently not supported for docker driver.
Please switch to a different driver (eg. "docker buildx create --use")
A second, subtler failure hides even when the build starts. If you build for a single foreign platform without QEMU registered, the RUN steps fail as soon as they execute a binary for the wrong architecture:
#!/usr/bin/env bash
set -euo pipefail
docker buildx build --platform linux/arm64 -t demo:arm --load .
=> ERROR [2/4] RUN apt-get update
exec /bin/sh: exec format error
exec format error means the kernel tried to run an arm64 binary on an amd64 host with no emulation layer installed. Both of these — the driver error and the format error — must be cleared before a single command can emit both architectures.
Root cause
The default builder created by Docker uses the docker driver, which writes results straight into the local Engine image store. That store is a single-architecture store: a repository tag maps to exactly one image config, so there is nowhere for a manifest list (the index that points at both an amd64 and an arm64 image) to live. BuildKit therefore refuses the request up front rather than silently dropping an architecture. The fix is a different driver — docker-container — which runs BuildKit inside a dedicated container that can assemble and export an OCI manifest list, either to a registry or to a local tarball.
The exec format error is a separate prerequisite. Building an arm64 image on an amd64 host means every RUN step executes arm64 binaries. The host kernel cannot do that alone; it needs binfmt_misc handlers that route foreign binaries through QEMU user-mode emulation. Docker Desktop ships these handlers preconfigured, but a plain Linux Engine install does not, which is why the same command that works on a Mac fails on a CI-like Ubuntu host until you register QEMU. Once both pieces are in place — the container driver for the manifest and QEMU for the foreign instructions — one machine can produce every architecture your team runs.
It helps to be precise about the two distinct things buildx is combining here, because conflating them is what makes the error confusing. The first is the output format: a single-platform build produces one image config plus its layers, while a multi-platform build produces an OCI image index — a small JSON document that lists each platform and the digest of the per-platform image it maps to. The second is execution: producing the arm64 image still requires running arm64 code somewhere. The container driver solves the output-format problem, and QEMU solves the execution problem; neither substitutes for the other. A build can fail the driver check before it ever reaches a RUN step, and it can pass the driver check and then fail on exec format error the instant an emulated instruction runs, so treat the two fixes as an ordered pair rather than alternatives.
Resolution
Register QEMU emulation handlers so the kernel can execute foreign-architecture binaries during
RUNsteps. This installsbinfmt_miscentries for every architecture the image supports:#!/usr/bin/env bash set -euo pipefail docker run --privileged --rm tonistiigi/binfmt --install all docker buildx ls | grep -i 'linux/'Create a
docker-containerbuilder and select it. The--useflag makes it the active builder for subsequent commands;--bootstrapstarts its BuildKit container immediately so the first real build does not pay the startup cost:#!/usr/bin/env bash set -euo pipefail docker buildx create \ --name multiarch \ --driver docker-container \ --bootstrap --use docker buildx inspect multiarch | grep -E 'Name|Driver|Platforms'Write a Dockerfile that stays architecture-neutral. Let BuildKit inject the target platform rather than hardcoding a base image digest for one arch, and use the automatic
TARGETPLATFORMbuild arg only where you genuinely need it:# Dockerfile FROM golang:1.22-alpine AS build ARG TARGETOS ARG TARGETARCH WORKDIR /src COPY go.* ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ go build -o /out/app ./cmd/app FROM alpine:3.19 COPY /out/app /usr/local/bin/app ENTRYPOINT ["app"]Build both platforms in one invocation. Pushing to a registry is the native way to store a manifest list, so tag for a real (or local) registry and push:
#!/usr/bin/env bash set -euo pipefail docker buildx build \ --builder multiarch \ --platform linux/amd64,linux/arm64 \ -t registry.local:5000/demo:multi \ --push .Keep it in Compose so teammates build the same matrix without memorizing flags. Compose v2 reads a
platformslist underbuildand drives buildx for you:# docker-compose.yml services: app: image: registry.local:5000/demo:multi build: context: . platforms: - linux/amd64 - linux/arm64Run
docker buildx bakeordocker compose buildwith themultiarchbuilder selected. If you only need the image loaded into your local Engine for a quick run, drop to a single platform with--load, because--loadcannot import a manifest list into the single-arch store.
Expected output
A correct multi-platform build streams two sets of steps — one per architecture — and finishes by exporting a manifest list. The tail of a successful --push build looks like this:
=> [linux/amd64 build 4/4] RUN go build -o /out/app ./cmd/app 6.1s
=> [linux/arm64 build 4/4] RUN go build -o /out/app ./cmd/app 41.8s
=> exporting to image
=> => exporting manifest list sha256:9c2f...: 0.0s
=> => pushing layers 3.4s
=> => pushing manifest for registry.local:5000/demo:multi
The two build 4/4 lines confirm both architectures ran, and the exporting manifest list line confirms the index was assembled. Verify the published result independently with docker buildx imagetools, which reads the manifest list back from the registry and prints one entry per platform:
#!/usr/bin/env bash
set -euo pipefail
docker buildx imagetools inspect registry.local:5000/demo:multi
Name: registry.local:5000/demo:multi
MediaType: application/vnd.oci.image.index.v1+json
Manifests:
Platform: linux/amd64
Platform: linux/arm64
Two Platform: lines under a single name is the definition of success: an Apple Silicon laptop pulling that tag gets the arm64 image, and an Intel CI runner pulling the identical tag gets the amd64 image, with no per-machine build required. The Docker client performs that selection automatically at pull time by matching the daemon's architecture against the index entries, so nothing downstream needs to know the image is multi-arch — docker run registry.local:5000/demo:multi just works on both hosts.
The emulated architecture is where build time concentrates, and the earlier chart makes the ratio concrete: the arm64 compile ran several times longer than its native amd64 counterpart on the same machine. That cost is real but bounded to the emulated half, so two levers keep it manageable. Ordering the Dockerfile so that expensive, arch-independent work (dependency downloads, module resolution) happens in a --platform=$BUILDPLATFORM stage means that work runs natively once instead of under emulation twice. Reserving the emulated path for the final compile and copy keeps the slow portion as small as the build allows, which is exactly the layer-ordering discipline that also governs fast local rebuilds.
Prevention
The two-architecture build works today; keeping it working means removing the ways it silently degrades to one architecture or breaks on a fresh machine.
- Pin the builder in a bootstrap script. A new clone has no
multiarchbuilder, so a teammate's first--platformbuild hits the driver error again. Add an idempotent setup step to yourmake bootstraptarget that creates the builder only if it is missing:docker buildx inspect multiarch >/dev/null 2>&1 || docker buildx create --name multiarch --driver docker-container --bootstrap. This ties into writing a make bootstrap target for one-command setup. - Gate the manifest in CI. A build can succeed while quietly emitting one architecture if someone drops a
--platformflag. Add a check that fails the pipeline when a platform is missing: pipedocker buildx imagetools inspect "$IMAGE" --rawthrough ajqassertion that bothlinux/amd64andlinux/arm64appear in.manifests[].platform. - Keep base images multi-arch. A build breaks with
no match for platform in manifestwhen aFROMimage lacks anarm64variant. Before adopting a base image, rundocker buildx imagetools inspect <image>and confirm it lists every platform you target; prefer official images, which are almost always multi-arch. Watch for env drift between the arches too, since a variable set on one platform's base but not the other surfaces exactly like the leaks covered in debugging env variable leakage in multi-stage Docker builds.
Platform caveats
macOS (Docker Desktop): QEMU handlers ship preinstalled, so you can skip the
binfmtstep, but emulatedamd64builds run through the Rosetta/QEMU layer and are slow — enable "Use Rosetta for x86/amd64 emulation" in Settings for a large speedup on Apple Silicon. WSL2: Registerbinfmtinside the WSL distro, not on Windows, and keep the build context on the Linux filesystem (~/, not/mnt/c); thedocker-containerbuilder reads context over the same 9p bridge that slows every cross-mount operation. Apple Silicon (ARM64):arm64is your native platform, so only theamd64half of the build is emulated. If your base image or a compiled dependency has noamd64wheel, the emulated build is where it will surface first.
Rollback
#!/usr/bin/env bash
set -euo pipefail
docker buildx use default
docker buildx rm multiarch || true
docker buildx prune -f
Switching back to the default builder restores single-architecture behavior; removing the multiarch builder tears down its BuildKit container, and prune reclaims the build cache it accumulated.
Frequently Asked Questions
Why does --platform linux/amd64,linux/arm64 fail with the docker driver?
The default builder uses the docker driver, which writes into the local Engine image store. That store maps one tag to exactly one image config, so it has no place to keep a manifest list that points at two architectures. BuildKit rejects the request rather than dropping an arch. Create a docker-container builder with docker buildx create --driver docker-container --use, which runs BuildKit in a container that can export an OCI manifest list.
Can I load a multi-arch image into my local Docker with --load?
No. --load imports the result into the single-architecture Engine store, which cannot hold a manifest list, so a two-platform build with --load fails. Use --push to a registry (a local registry:2 works fine) to store the full manifest list, or build a single platform with --load when you only need to run the image locally on your host architecture.
Do I need QEMU if I only build for my own architecture?
No. QEMU is only needed to execute foreign-architecture binaries during RUN steps. If every target platform is one your host can run natively, the exec format error never appears. The moment you add a foreign platform — arm64 on an Intel host or amd64 on Apple Silicon — install the handlers with docker run --privileged --rm tonistiigi/binfmt --install all so the kernel can route those binaries through emulation.
How do I confirm both architectures actually made it into the published image?
Run docker buildx imagetools inspect <image> and read the Manifests section. A correct multi-arch image is an image index whose entries list each Platform: you built, such as linux/amd64 and linux/arm64. For a scriptable CI gate, add --raw and pipe the JSON through jq to assert that every required platform appears in .manifests[].platform.