Multi-Architecture Builds for ARM & x86
A team on Apple Silicon laptops, x86_64 CI runners, and ARM cloud instances needs one image that runs native on each — not a single-arch image limping along under emulation. This guide covers building and publishing multi-architecture dev images with buildx, QEMU, and TARGETARCH-aware Dockerfiles, so every host pulls its own variant. The rise of ARM — Apple Silicon on the desktop, ARM instances in the cloud — has made this a mainstream concern rather than a niche one, and a development image that ignores it quietly penalizes an ever-growing fraction of engineers. It operationalizes the cross-platform parity section of the architecture guide.
The failure this prevents is subtle and expensive: a single amd64 image works on an M-series Mac via emulation, but it is slow, and it may install amd64-native modules that behave incorrectly. Native multi-arch removes both the speed tax and the correctness risk.
The insidious quality of an architecture mismatch is that it almost never fails loudly. The image starts, the editor attaches, the tests run — everything appears to work, just slowly, and occasionally with a native dependency that segfaults or behaves differently than it does in CI. Because nothing errors outright, the cost hides as a persistent drag on every developer running the emulated architecture, and it surfaces as intermittent, hard-to-reproduce bugs that get blamed on everything except the architecture. A team can lose considerable time to "flaky" behaviour that is really an amd64 image limping along on arm64 hardware, which is exactly why the fix — publishing genuine native images per architecture — is worth the build complexity it adds.
Prerequisites
You need Docker buildx (the BuildKit-based builder), QEMU registered for cross-platform emulation, a registry that supports manifest lists (Docker Hub, GHCR, ECR all do), and a Dockerfile that reacts to TARGETARCH.
Before assembling the tooling, it is worth being honest about whether you need multi-arch at all, because the answer determines how much of this guide applies. If every developer and every CI runner shares one architecture — an all-Intel shop, say — a single-architecture image is perfectly reproducible and multi-arch is unnecessary complexity. The moment even one machine differs — a single Apple Silicon laptop, one ARM cloud runner — the calculus flips, because that machine is now either running the wrong architecture under emulation or unable to run the image at all. Mixed hardware is increasingly the norm rather than the exception, so most teams do eventually need multi-arch; the point is to adopt it because your hardware is genuinely mixed, not reflexively.
The conceptual prerequisite is understanding that a "multi-architecture image" is not one image that runs everywhere but a manifest list that points at several per-architecture images, one of which the registry selects for each host. This distinction shapes everything: you are not building a magic universal binary, you are building one image per architecture and publishing an index that ties them together under a single tag. Once that model is clear, the tooling — buildx to build the variants, QEMU to build a foreign architecture on your machine, a manifest-list-capable registry to hold the index — falls into place as the machinery for producing and publishing that structure.
docker buildx versionresolves and a builder instance is created.- QEMU binfmt handlers installed (
docker run --privileged tonistiigi/binfmt --install all). - A registry account that accepts multi-platform manifests.
- A Dockerfile that uses
$TARGETARCH/$TARGETPLATFORMrather than hardcoded arch strings.
Of these, the two that cause the most confusion when missing are the QEMU registration and the builder driver. If binfmt handlers are not installed, a build that needs to run a foreign-architecture step fails with an exec format error — the machine cannot execute the other architecture's binaries. And if the buildx builder uses the default docker driver rather than docker-container, the --platform flag is silently ignored and you get a single-architecture image despite asking for several. Both produce symptoms that look unrelated to their cause, so confirming binfmt is registered and the builder uses the container driver up front rules out the two most common "why isn't multi-arch working?" dead ends before you start.
Architecture & Configuration Deep Dive
A multi-arch image is a manifest list: a single tag that points at several per-architecture images (an amd64 image and an arm64 image, for example). When any host pulls the tag, the registry serves the variant matching that host's platform. BuildKit produces those variants by building the Dockerfile once per platform, exposing $TARGETPLATFORM, $TARGETARCH, and $TARGETOS as build args so the Dockerfile can select architecture-appropriate downloads.
The manifest list is the piece that makes the whole thing feel like one image while actually being several. Without it, you would have to publish myimage-amd64 and myimage-arm64 as separate tags and teach every consumer to pick the right one — brittle and error-prone. The manifest list collapses that into a single tag myimage, and delegates the per-host selection to the registry, which reads the pulling client's platform and serves the matching variant automatically. This is why a correctly-built multi-arch image is transparent to developers: they reference one tag, and each gets their native variant without knowing the mechanism exists. The transparency is the goal, and the manifest list is what delivers it.
Understanding this structure also clarifies what "pinning a multi-arch image" means, which is a common point of confusion. The tag resolves through the manifest list to per-architecture digests, so you can pin at two levels. Pinning the manifest list's digest keeps the multi-arch selection intact — every host still gets its native variant, and the whole list is fixed. Pinning a specific per-architecture digest fixes exactly one platform and defeats the multi-arch behaviour. For a development image on mixed hardware you almost always want the former: pin the manifest list so the image is both reproducible and correctly multi-arch, letting each host resolve its own native variant from the fixed list.
The Dockerfile discipline is to never hardcode an architecture. Instead of downloading tool-linux-amd64, branch on $TARGETARCH to fetch tool-linux-${TARGETARCH}. This is what lets one Dockerfile produce correct native images for every platform, and it pairs with the digest-pinning from registry best practices so each variant is itself reproducible.
BuildKit provides the architecture as build arguments precisely so the Dockerfile can react to it. $TARGETPLATFORM (like linux/arm64), $TARGETOS, and $TARGETARCH (like arm64) are populated automatically for each platform BuildKit is building, so a single Dockerfile compiled once per platform sees a different $TARGETARCH in each build. The discipline, then, is to route every architecture-dependent download or decision through these variables: fetch the matching binary, install the matching wheel, select the matching package. Where a language ecosystem already keys off the architecture — Python's manylinux/musllinux wheels, Node's native addons, Go's cross-compilation — you often get correct behaviour for free, but any manual download of a prebuilt binary must be parameterized by $TARGETARCH or it will install one architecture's binary into every variant.
The most insidious multi-arch bug is not a build failure but a silent wrong-architecture install. A Dockerfile that hardcodes an amd64 binary download builds successfully for the arm64 variant too — it just bakes an amd64 binary into an arm64 image, which then either fails to execute at runtime or, worse, runs under a translation layer with subtle behavioural differences. Because the build did not error, the problem hides until a developer on that architecture hits it. This is why the "never hardcode an architecture" rule is absolute rather than a nicety: a hardcoded arch does not announce itself at build time, so the only defense is to parameterize every architecture-dependent step and verify the produced binary's architecture matches its image.
Step-by-Step Implementation
The build-and-push flow is four commands: create a buildx builder that can emulate, build for both platforms in one invocation, push the resulting manifest list under one tag, and verify the platforms are present.
Two details in this flow trip people up if they are missed. First, the buildx builder must use the docker-container driver, not the default docker driver, because only the container driver runs a full BuildKit instance capable of multi-platform builds — with the default driver, --platform is silently ignored and you get a single-architecture image despite asking for two. Second, QEMU's binfmt handlers must be registered on the machine before it can build a foreign architecture; without them, a cross-platform build step fails with an exec format error that looks cryptic until you realize the machine literally cannot execute the foreign binaries the build invokes. Registering binfmt once (docker run --privileged tonistiigi/binfmt --install all) and using the container driver are the two setup steps that turn "why is --platform doing nothing?" into a working multi-arch build.
The single-invocation, push-in-one-step nature of the flow is deliberate and worth preserving. Building both platforms in one buildx build --push command produces and publishes the manifest list atomically, so consumers never see a half-published image with only one architecture. Splitting the build and push, or building platforms separately and merging manifests by hand, is possible but introduces windows where the tag points at an incomplete set of variants. For most teams the one-command build-and-push is both simpler and safer; reach for manual manifest merging only when you genuinely need to build the platforms on separate native runners and stitch them together afterward.
# 1. Create a builder that can build for multiple platforms
docker buildx create --name multiarch --use
# 2. Build both platforms and push the manifest list in one step
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/acme/dev-image:1.0 --push .
# 3. Verify both platforms are present under the one tag
docker buildx imagetools inspect ghcr.io/acme/dev-image:1.0
The full buildx + QEMU walkthrough, including caching across platforms, is in building multi-arch images with buildx and QEMU. A Dockerfile fragment that selects binaries by architecture:
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED
ARG TARGETARCH
RUN curl -fsSL "https://example.com/tool-linux-${TARGETARCH}.tar.gz" | tar -xz -C /usr/local/bin
USER vscode
Performance & Resource Optimization
Build strategy is a real performance decision. Native builds (on a runner of the target architecture) are fastest and most correct. QEMU emulation lets one machine build every platform but is markedly slower — acceptable in CI, painful for iterative local builds. Cross-compilation inside the image (compiling for the target arch on the host arch) is a middle path for compiled languages.
For CI, the pragmatic setup is native builders per architecture where available (for example arm64 and amd64 runners) with a manifest-merge step, falling back to QEMU only for the platforms you lack native runners for. Cache the platform layers so a small change doesn't re-emulate the world. Runtime always wins from native: no host pays the emulation tax once the image is published.
Caching is especially important for emulated builds because emulation multiplies the cost of every uncached layer. Under QEMU, each instruction of the foreign build runs through translation, so a cache miss that would cost seconds natively can cost minutes emulated. Pointing --cache-to and --cache-from at a registry cache lets an emulated build reuse unchanged layers from a previous run rather than re-emulating them, which is the difference between an emulated build that is merely slow and one that is unusable. Combined with stable-first Dockerfile layer ordering, registry caching keeps even the QEMU fallback path fast enough to run on a schedule or a merge without dominating the pipeline.
The three build strategies trade off in a way worth making explicit, because the right choice depends on what you are optimizing. Native builds — running the build on a runner of the target architecture — are fastest and most correct, but require having a runner of each architecture, which not every CI setup offers, though the growing availability of ARM runners on the major CI platforms is steadily making native multi-arch builds the practical default. QEMU emulation lets a single machine build every platform by emulating the foreign architecture; it is correct but markedly slower, since every instruction of the foreign build is translated. Cross-compilation, for compiled languages, builds for the target architecture on the host architecture using a cross toolchain; it is faster than emulation but requires the language and its dependencies to support cross-compiling. Most teams land on a hybrid: native where runners exist, QEMU as the universal fallback, and cross-compilation for the compiled components that support it.
A crucial framing is that build-time slowness and runtime slowness are entirely different concerns with different acceptability. A slow build under QEMU in CI is a one-time cost paid by the pipeline, and it is usually acceptable if it happens on a schedule or a merge rather than on every commit. A slow runtime under emulation is paid by every developer, every day, on every operation, and it is never acceptable. This is why the goal is always to publish native per-architecture images even if you had to build some of them slowly under emulation: you are willing to spend build time to eliminate runtime emulation, because the build cost is bounded and centralized while the runtime cost is unbounded and distributed across the whole team.
Validation & Testing
Validate that the manifest lists every platform you support and that each host pulls its native variant rather than emulating. docker buildx imagetools inspect lists the platforms; on an actual host, docker image inspect shows the pulled architecture matches the host.
These two checks map onto the two failure families and should both run. Inspecting the manifest with imagetools confirms the publishing side is complete — that every platform you intend to support actually appears in the list — which catches the missing-variant failure at build time rather than when a developer's pull falls back to emulation. Checking the running image's architecture against the host confirms the content side is correct — that the host genuinely pulled and is running its native variant, not an emulated foreign one. Because the failure modes are silent, neither check is optional: a manifest that looks fine but omits a platform, and a host that quietly emulates, both pass a casual "does it work?" test while being exactly the problems multi-arch exists to prevent.
Building the verification into CI closes the loop. A pipeline step that runs imagetools inspect and asserts the expected platforms are present will fail the build if a --platform target was accidentally dropped, turning a silent omission into a loud, immediate error. This is the same principle applied throughout reproducible-environment work: convert failures that would otherwise be discovered late, by an affected user, into failures caught early, by an automated gate. Multi-arch is especially in need of this because its failures are so quiet — an incomplete manifest and a wrong-architecture binary both build green — so an explicit assertion is the only reliable defense.
# Confirm the pulled image's architecture matches the host (no emulation)
docker image inspect ghcr.io/acme/dev-image:1.0 --format '{{.Architecture}}'
uname -m
Common Pitfalls
The failures below come from either an incomplete manifest or a Dockerfile that assumes one architecture. The triage below distinguishes them.
The two failure families here mirror the two things multi-arch actually requires: a complete manifest and an architecture-neutral Dockerfile. Manifest problems — a platform missing from the list, a host pulling the wrong variant — mean the publishing side is incomplete, and the fix is on the build-and-push command (list every platform, push a proper manifest list). Dockerfile problems — a native module crashing, a binary that does not execute — mean the content side assumed an architecture, and the fix is in the Dockerfile (parameterize by $TARGETARCH). Classifying a failure into one of these two families tells you immediately whether to look at how the image is built and pushed or at what the Dockerfile puts inside it.
The reason these failures are worth a triage rather than a glance is that several of them are silent. A manifest missing the arm64 variant does not error on an amd64 machine; it only surfaces when an Apple Silicon developer's pull falls back to emulation or fails. A hardcoded amd64 binary builds cleanly for every platform; it only surfaces at runtime on the non-amd64 hosts. Because the build stays green, these problems are discovered by the affected developers rather than by CI, which is exactly backwards. The remedy is to verify rather than assume: inspect the manifest for every platform you support, and on each host confirm the running image's architecture matches the host.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Apple Silicon runs slow / emulated | Image is amd64-only, no arm64 variant | Build multi-arch and push a manifest list |
| Native module crashes on arm64 | Dockerfile fetched amd64 binaries | Select downloads via $TARGETARCH |
| Manifest missing a platform | Build omitted a --platform target | Re-run buildx build with both platforms |
| Local multi-arch build is painfully slow | Emulating both platforms on one host | Use native runners per arch, QEMU as fallback |
| Variants drift from each other | Base pinned differently per build | Pin the base by digest for all platforms |
Conclusion
One image, native everywhere. Build a manifest list that carries both an amd64 and an arm64 variant, write the Dockerfile to select downloads by $TARGETARCH, and verify each host pulls its own architecture. The build costs more time than a single-arch image, but it buys native runtime speed and correct native modules for every developer — and it removes the silent emulation tax that a single-arch image imposes on half your team.
The trade you are making is deliberate and favourable: a bounded, one-time increase in build complexity and build time in exchange for eliminating an unbounded, recurring runtime cost spread across every developer on the "wrong" architecture. Framed that way, the decision is easy for any team with genuinely mixed hardware, because the build cost is paid once by a pipeline while the emulation cost would be paid forever by people. The complexity is real but contained — a buildx builder, QEMU registration, a $TARGETARCH-aware Dockerfile — and once it is set up it recedes into the background, producing correct native images on every build without further attention.
The verification habit is what keeps multi-arch honest over time, because the failure modes are silent. Make it routine to inspect the published manifest for every platform you support and, on each host, to confirm the running image's architecture matches the host. These two checks catch the missing-variant and wrong-binary problems before a developer does, which is the whole point: multi-arch that is built but never verified can drift back into single-arch or wrong-arch without anyone noticing until it hurts. Build native, publish a complete manifest, and verify — and every developer, on whatever hardware, gets a correct, native, fast environment from one tag.
FAQ
Do I really need multi-arch if most of my team is on one platform? If any developer or runner is on a different architecture — one Apple Silicon laptop, one ARM cloud runner — yes. A single-arch image forces those hosts into emulation, which is slow and can install architecture-wrong native modules. Multi-arch is the only way to give every host a correct, native environment from one tag. If your entire team and all your runners genuinely share one architecture, a single-arch image is fine and multi-arch is unnecessary — but verify that assumption, because a single differing machine is enough to make it worthwhile, and mixed hardware tends to creep in as teams grow and hardware refreshes.
Is QEMU emulation good enough, or must builds be native? QEMU is fine for building in CI when you lack native runners — it is slower but correct. It is not something you want at runtime: publish a native multi-arch manifest so hosts never emulate the running container. For iterative local builds, prefer native or cross-compilation over emulation.
How do I stop the per-architecture variants from drifting apart?
Pin the base image by digest for every platform and select all downloads by $TARGETARCH from the same Dockerfile, so both variants are built from identical instructions. Then verify with imagetools inspect that the manifest lists exactly the platforms you intend. The key is that both variants come from one Dockerfile built once per platform, not from two separately-maintained files — a single source parameterized by $TARGETARCH cannot drift the way two hand-maintained per-arch Dockerfiles would, because there is only one set of instructions and the architecture is the only thing that varies.
Is QEMU emulation good enough, or must builds be native?** QEMU is fine for building in CI when you lack native runners — it is slower but produces correct images. It is not something you want at runtime: publish a native multi-arch manifest so hosts never emulate the running container. The distinction is between a bounded, one-time build cost and an unbounded, recurring runtime cost. You are willing to spend slow QEMU build time in the pipeline precisely so that no developer ever pays emulation cost at runtime. For iterative local builds, prefer native or cross-compilation over QEMU, since emulating every build locally is painfully slow; reserve QEMU for the CI platforms you cannot build natively.
Related
- DevContainer Architecture & Core Tooling — the parent guide framing cross-platform parity.
- Building Multi-Arch Images with buildx and QEMU — the detailed build workflow with caching.
- Container Registry Best Practices for Dev Images — pinning and manifest hygiene for multi-arch images.
- devcontainer.json Property Reference — the
build.argsand platform-related keys. - Understanding the DevContainer Specification — how build args reach the Dockerfile from the spec.