Your developers build on Apple Silicon. Your servers run x86. The image builds clean on a laptop, gets pushed, and the first thing production says is exec format error. The image is arm64; the host is amd64; nothing runs.
The usual patch is a per-architecture tag scheme — app:1.4.0-amd64, app:1.4.0-arm64 — and a deployment step that picks the right one. It works until someone forgets, and then you are back to the format error, this time at 2am. The proper fix is to build a single tag that carries both architectures and let the container runtime pull whichever it needs. That is what a multi-arch image is, and docker buildx produces one in a single command.
What the runtime actually resolves
A tag like myorg/app:1.4.0 does not have to point at one image. It can point at a manifest list (the OCI term is an image index) — a small JSON document that maps each platform to its own image digest. When a host pulls the tag, the runtime reads the list, matches its own os/arch, and pulls only the matching image. The amd64 server gets the amd64 layers; the arm64 laptop gets the arm64 layers; the tag is identical on both. No suffixes, no deployment-time branching, no format error.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
The single-arch tag your laptop produces by default is the problem. docker build builds for the host architecture and nothing else. To emit a manifest list you need buildx and a builder that can target more than the local platform.
Set up a builder that can cross architectures
The default docker driver only builds for the host. Multi-platform output needs the docker-container driver, which runs BuildKit in its own container and can produce and hold a manifest list. Create one and make it active:
# Create a BuildKit builder that supports multi-platform output
docker buildx create \
--name container-builder \
--driver docker-container \
--bootstrap --use
# Confirm which platforms it can target
docker buildx inspect --bootstrap
To build a foreign architecture on a single host you need emulation. BuildKit ships with QEMU, but on older engines or CI runners the binfmt handlers may not be registered. Install them explicitly — this is a one-off per machine (it does not persist across a reboot on some hosts, so CI usually re-runs it):
# Register QEMU handlers so the kernel can run non-native binaries
docker run --privileged --rm tonistiigi/binfmt --install all
Build and push both architectures under one tag
With the builder active, one command builds both platforms and pushes the manifest list. The --push flag matters: the docker-container driver does not load results into your local engine image store, so --load cannot hold a multi-arch result. You push straight to the registry, where the manifest list is assembled for you.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag myorg/app:1.4.0 \
--tag myorg/app:latest \
--push \
.
That is the whole fix. One tag, two architectures, resolved by the runtime on pull. The per-arch tag scheme can be deleted.
Emulation is correct but slow — cache and go native where it hurts
QEMU emulation is faithful but expensive. A compile-heavy build — anything that runs a native toolchain, compiles C extensions, or bundles assets — can be several times slower under emulation than native. Two things keep it tolerable.
First, cache aggressively. In CI, a registry or GitHub Actions cache backend keeps unchanged layers off the emulated critical path:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from type=gha \
--cache-to type=gha,mode=max \
--tag myorg/app:1.4.0 \
--push .
Second, when emulation is genuinely too slow, stop emulating. Add a native arm64 node to the same builder so each architecture builds on its own metal, in parallel, and buildx still stitches the manifest list together:
# amd64 host creates the builder, then appends a native arm64 node
docker buildx create --name container-builder \
--driver docker-container --use
docker buildx create --append --name container-builder \
ssh://ci@arm64-host
The other lever is the Dockerfile itself. For compiled languages, cross-compiling with the build platform’s toolchain and the TARGETARCH build argument avoids emulation entirely — you build natively and target the foreign arch. That is a larger change, but for Go and Rust it is the fastest option by a wide margin.
Verify the manifest, do not assume it
A build that succeeds is not proof that both architectures landed. Inspect the pushed tag and read the platforms back:
docker buildx imagetools inspect myorg/app:1.4.0
You want to see a manifest list with two entries — linux/amd64 and linux/arm64 — each with its own digest. If you see a single manifest, the build fell back to one platform and the format error is still waiting for you. Make this check a CI gate, not a manual habit: assert the platform list, fail the pipeline if either architecture is missing. It belongs in the same class of machine-enforced baseline checks as linting and secret scanning — a thing no one has to remember.
One caveat worth knowing: Docker Engine 29.0 and later, and current Docker Desktop, use the containerd image store by default, which handles multi-platform images natively — --load of a multi-arch result works there. On older engines it does not, which is why the push-to-registry path above is the reliable one across a mixed fleet.
The payoff is not just the disappearance of exec format error. It is one artefact, one digest per platform, one thing to sign and scan and promote through environments. That consistency is what makes build health measurable in the first place — the kind of thing that shows up cleanly in your deployment frequency and change-failure metrics rather than as a mystery incident. Fix it at the manifest, and the architecture of the host stops being something anyone downstream has to think about.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.