Building Multi-Arch Images with buildx and QEMU
You need one image that runs native on amd64 and arm64, but you only have one machine to build on. This page uses docker buildx with QEMU emulation to build both platforms and push a single manifest list, plus caching so a small change doesn't re-emulate the world.
This matters because a dev team rarely runs on one architecture anymore. Some engineers are on Apple Silicon laptops that are arm64 to the core, the CI fleet and production hosts are usually amd64, and a cloud arm64 runner might sit somewhere in between. If you publish a single-architecture dev-image, half the team pulls an image that either refuses to start or silently falls back to slow emulation on their own machine. A manifest list solves that: one tag, ghcr.io/acme/dev-image:1.0, resolves to the right per-platform image automatically when a client pulls it, so nobody has to remember an architecture suffix or maintain parallel tags by hand.
The mental model is worth holding onto before you touch a command. QEMU's binfmt handlers let a single amd64 host execute arm64 instructions, so BuildKit can run every RUN step in the arm64 stage even though the CPU underneath is x86. buildx orchestrates two builds — one per --platform target — and stitches their outputs into a manifest list at push time. Emulation is the expensive part: an emulated compile step can run several times slower than native, which is why the registry cache in step 3 is not optional polish but the thing that keeps iterative builds short. Reach for this approach when you lack native hardware for one of the architectures; if you have both, native runners merged into a manifest will always beat QEMU on wall-clock time.
Prerequisites
You need buildx, QEMU binfmt handlers, and a manifest-list-capable registry.
docker buildx versionresolves.- QEMU binfmt registered (
tonistiigi/binfmt --install all). - A registry that accepts multi-platform manifests.
Each of these three is a distinct capability, and it helps to know why. docker buildx version resolving confirms the CLI plugin is present; on modern Docker it ships in the box, but a stripped-down engine or an old distro package may not have it. The QEMU binfmt registration is host-wide kernel state, not something baked into a single builder, which is why it survives across builds but is lost on a host reboot unless you re-run the installer or persist it. The registry requirement is easy to overlook: most modern registries speak the OCI image index format that a manifest list needs, but some older or self-hosted registries reject a multi-platform push, and you only discover it when --push fails.
The one detail people get wrong is running these prerequisites inside a container or CI job that lacks the privilege to modify the host's binfmt_misc. The tonistiigi/binfmt --install all step writes to /proc/sys/fs/binfmt_misc, so it needs --privileged and a writable host. In a nested or locked-down CI environment that write silently no-ops or errors, and the failure only surfaces later as an exec format error deep in the arm64 build — long after you assumed emulation was ready.
Step-by-Step Implementation
- Register QEMU so one machine can build other architectures.
docker run --privileged --rm tonistiigi/binfmt --install all
This one-shot container registers QEMU emulators with the host kernel's binfmt_misc subsystem, so that when the kernel later meets an arm64 ELF binary it hands it to the QEMU user-mode interpreter instead of returning exec format error. The --privileged flag is mandatory because the container has to write emulator entries into /proc/sys/fs/binfmt_misc, which is a host-level mount; a normal container can't touch it. --rm is there because the container has no job once the handlers are installed — the state it created lives in the kernel, not in the container, so keeping it around would only leave clutter. Running --install all registers every architecture tonistiigi/binfmt knows about; installing all of them costs nothing and spares you a second trip when a third platform shows up.
- Create a buildx builder that uses the BuildKit container driver.
docker buildx create --name multiarch --driver docker-container --use
The default docker driver builds straight into the local image store, which can only hold one architecture per tag and therefore cannot emit a manifest list at all. The docker-container driver spins up a dedicated BuildKit instance in its own container, and that full BuildKit is what makes concurrent multi-platform builds, registry cache exports, and manifest assembly possible. Naming it (--name multiarch) means you can inspect it later with docker buildx ls and remove it cleanly, rather than fighting an anonymous builder. The --use flag makes it the active builder for subsequent commands so you don't have to pass --builder multiarch on every invocation; forget this step and your --platform flag in the next command gets quietly ignored because the old default driver is still selected.
- Build both platforms with caching and push the manifest list.
docker buildx build --platform linux/amd64,linux/arm64 \
--cache-to type=registry,ref=ghcr.io/acme/dev-image:cache,mode=max \
--cache-from type=registry,ref=ghcr.io/acme/dev-image:cache \
-t ghcr.io/acme/dev-image:1.0 --push .
This single invocation is the heart of the workflow. --platform linux/amd64,linux/arm64 tells BuildKit to run the whole Dockerfile twice, once per target, with the arm64 pass driven through QEMU on an amd64 host. The --cache-to type=registry,...,mode=max line pushes every intermediate layer — not just the final ones — into a dedicated :cache tag in the registry, and mode=max is what captures the intermediate stages that mode=min would drop; this is precisely the data a later build reads back through --cache-from to skip re-emulating unchanged steps. Keeping the cache under a separate ref (dev-image:cache) rather than the release tag means your cache churn never rewrites 1.0. Finally, --push matters because a manifest list has nowhere to live locally — the default docker image store can't represent it — so buildx assembles the index and pushes it to the registry in one motion; swap --push for --load and the command fails the moment it tries to load two architectures into a store that holds one.
- Verify both platforms are present under the one tag.
docker buildx imagetools inspect ghcr.io/acme/dev-image:1.0
imagetools inspect reads the manifest list back from the registry without pulling any image data, and its output should show two entries under Manifests, one with Platform: linux/amd64 and one with linux/arm64. This is the check that catches the most common silent failure: a build that succeeded but only emitted one architecture because a --platform target was dropped, leaving arm64 users to pull an amd64 image that runs under slow emulation on their own machines. Because it queries the registry rather than the local store, it also confirms the push genuinely landed the index. Make this verification a reflex after every publish — it is the difference between assuming the manifest is correct and knowing it.
Common Pitfalls
Buildx failures come from a missing binfmt registration, the wrong driver, or no cache.
The registry cache introduces its own quieter class of problems, most of them about permissions. The :cache tag lives in the same repository as your release image, and whatever identity runs the build needs write access to push cache-to and read access to pull cache-from. In CI this bites when a pull-request pipeline runs with a read-only token: cache-from works and speeds up the build, but cache-to fails to write, so the cache never refreshes and every run re-emulates from an increasingly stale baseline. If the cache tag was first pushed by a different account, registry-side permissions or immutability rules can also block the overwrite, again surfacing only as a slow build rather than a hard error. Treat the cache ref as a first-class artifact with the same access model as the image itself.
The topic-specific trap worth calling out is assuming QEMU behaves identically to native hardware. Emulated builds occasionally expose bugs that never appear natively — a package's post-install script that probes CPU features, a compiler that miscompiles under emulation, or a test that times out because emulated execution is several times slower. When an arm64 build fails in a way the amd64 build does not, resist the urge to blame your Dockerfile first; confirm whether the same step passes on genuine arm64 hardware. The durable fix for anything QEMU-sensitive is to build that architecture on a native runner and merge it into the manifest, reserving emulation for the platforms where you truly have no native option.
| Symptom | Root Cause | Remediation |
|---|---|---|
| exec format error during build | QEMU binfmt not registered | Run tonistiigi/binfmt --install all |
| --platform ignored / one arch built | Default docker driver, not container | Use --driver docker-container |
| Every build re-emulates fully | No cache-to/cache-from | Add a registry cache |
| Manifest missing a platform | Build omitted a --platform target | List both platforms in one build |
Conclusion
One machine, two architectures, one manifest. Register QEMU, use a docker-container buildx driver, build both platforms in a single invocation with a registry cache, and push the manifest list. The cache is what makes emulation bearable — without it, every build re-emulates from scratch.
The strategic payoff is that the architecture split disappears from everyone's daily workflow. Once ghcr.io/acme/dev-image:1.0 is a manifest list, an Apple Silicon developer, an amd64 CI job, and an arm64 cloud runner all pull the same tag and each transparently receives the image built for their CPU. There is no per-machine documentation, no -arm64 suffix to remember, and no support thread that starts with "it works on my laptop but not in CI." That uniformity is worth more than the raw build speed, because it removes a whole category of environment-drift questions from the team.
This ties directly into the broader pin-and-cache and reproducibility themes that run through multi-arch work. The registry cache under a dedicated :cache ref gives you deterministic, shareable layer reuse across machines and CI runs, so a build on a fresh runner reconstructs the same layers a teammate already produced. Pairing that cache with an immutable release tag and a digest you record from imagetools inspect closes the loop: you can point back to exactly which per-platform images a manifest resolved to, reproduce them from cache, and trust that the arm64 and amd64 halves came from the same source in the same build. Emulation gets you multi-arch coverage today; the cache and the pinned manifest are what make that coverage repeatable tomorrow.
FAQ
Why do I get exec format errors when building for arm64?
Because QEMU's binfmt handlers aren't registered, so your machine can't execute arm64 binaries during the build. Run docker run --privileged --rm tonistiigi/binfmt --install all once to register the emulators, and the cross-platform build steps can run. Remember that this registration is host kernel state, so it does not survive a reboot and is not inherited by an unprivileged CI container; if the error reappears after a machine restart or only inside CI, re-run the installer on that host with the privilege it needs. The same error also shows up at runtime, not just at build time, when a client on one architecture pulls a single-arch image built for the other — which is the case a proper manifest list prevents.
Why is --platform being ignored?
The default docker buildx driver can't produce multi-platform images. Create a builder with --driver docker-container (which uses a full BuildKit instance) and select it with --use. Then --platform linux/amd64,linux/arm64 builds both variants. The subtle part is that --use has to have taken effect: if you created the builder in one shell and are building in another, or the create step failed quietly, buildx falls back to the default driver and drops the extra platform without complaint. Confirm the active builder with docker buildx ls and look for the * next to multiarch before you trust the build.
How do I stop emulated builds from being painfully slow?
Cache aggressively with --cache-to/--cache-from pointing at a registry cache, so unchanged layers aren't re-emulated. Where possible, use native runners per architecture and merge into a manifest, reserving QEMU only for platforms you lack native hardware for. Beyond caching, order your Dockerfile so the expensive, rarely-changing steps — base package installs, language toolchains — come before the fast-changing application layers, which lets the cache absorb the emulated cost and leaves only cheap steps to rebuild. Using mode=max on the cache export is what preserves those intermediate stages for reuse; mode=min would drop them and force re-emulation next time.
Do I need to write a separate Dockerfile for each architecture?
No. A single Dockerfile builds every platform, because buildx runs it once per --platform target and QEMU handles the architecture-specific execution. If you need architecture-aware logic in the Dockerfile, use the automatic build arguments BuildKit provides — TARGETARCH, TARGETPLATFORM, and BUILDPLATFORM — to branch on which platform is being built. That keeps one source of truth while still letting a step download the right binary or set an arch-specific flag.
Can I add linux/arm/v7 or other platforms to the same manifest?
Yes, as long as your base images publish those platforms and QEMU has a handler for them, which --install all provides. Extend the --platform list, for example linux/amd64,linux/arm64,linux/arm/v7, and buildx adds each as another entry in the manifest list. Every extra platform multiplies emulated build time and cache size, so add only the targets you actually deploy to.
How do I know the arm64 half wasn't silently built from cache staleness?
Record the per-platform digests from docker buildx imagetools inspect ghcr.io/acme/dev-image:1.0 and compare them against the tag you expect. Because the cache lives under a separate :cache ref and the release tag is pushed fresh each build, a mismatched or unexpectedly unchanged digest is your signal that something reused a stale layer or skipped a platform.
Related
- Up to Multi-Architecture Builds for ARM & x86 — the parent guide on multi-arch strategy.
- Container Registry Best Practices for Dev Images — manifest and digest hygiene.
- Choosing Between Alpine and Debian Base Images — base choice across architectures.