An image built on an Apple Silicon laptop and pushed to a registry, then pulled onto an x86 production server: the container dies on start with exec format error. Or the image starts, then crashes the first time it loads a native library. Both come from the same fact. Containers aren’t virtual machines; they run directly on the host’s CPU and kernel, so the machine code inside has to match the host’s architecture.
The examples were run on an arm64 Mac (Docker Desktop, containerd image store, BuildKit). Where a step would need images for another architecture that weren’t on the machine, that’s noted.
One tag, several images
Pull alpine:3.20 on an arm64 host and you get arm64 binaries. Pull the same tag on an x86 server and you get x86 binaries. The tag points at an image index (also called a manifest list), which lists one image manifest per platform. With the containerd image store, docker image ls --tree shows the structure:
alpine:3.20 d9e853e87e55 13.7MB 4.17MB
├─ linux/amd64 c64c687cbea9 0B 0B
├─ linux/arm/v6 37753b296504 0B 0B
├─ linux/arm/v7 bd05c4d38cbe 0B 0B
├─ linux/arm64/v8 45e09956dc66 13.6MB 4.09MB
├─ linux/386 4ec3ead63e75 0B 0BThe index knows about many platforms, but only the arm64 variant was downloaded, because that’s what this host needed. docker run picks the matching entry automatically, and --platform linux/amd64 asks for a different one.
Images you build yourself are single-platform by default: whatever the builder’s architecture is. Push that from a Mac and the registry holds an arm64-only image. An x86 server pulling it either refuses with “no matching manifest for linux/amd64” or, if the tag has only one manifest and no index, pulls it anyway and fails at start-up.
What exec format error actually means
The kernel loads executables by reading their header. An ELF binary records its target CPU:
$ file hello
hello: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux), statically linked, ...Ask an x86-64 kernel to execute that and it returns ENOEXEC, which surfaces as exec /app/hello: exec format error. Nothing in Docker translates instructions. The exec format error guide walks through diagnosing it, including the other cause (a script without a shebang line).
Docker Desktop hides this on Macs by registering QEMU user-mode emulation through the kernel’s binfmt_misc mechanism, so docker run --platform linux/amd64 works there, slowly. On a plain Linux server, emulation exists only if someone installed it (docker run --privileged --rm tonistiigi/binfmt --install all is the common way). This is why “it runs on my Mac with --platform linux/amd64” doesn’t prove much: the laptop is emulating; production isn’t.
Native dependencies are where it really breaks
Pure interpreted code is portable: a JavaScript file or a Python module runs on any CPU that has the interpreter. The interpreter itself comes from the base image, which is per-platform, so that part takes care of itself. The trouble is compiled pieces your dependencies bring:
- Node packages with native addons (
bcrypt,sharp,better-sqlite3,canvas) compile or download a.nodeshared library for the current platform duringnpm install. - Python wheels like
numpy,cryptographyandpsycopg2-binaryare published per platform and per libc (manylinuxfor glibc,musllinuxfor Alpine’s musl). - Go binaries with cgo, Rust crates linking system libraries, and Java apps with JNI libraries.
The classic failure: a developer runs npm install on macOS, and the Dockerfile does COPY . ., so the macOS arm64 node_modules overwrites what the image installed. The container starts and then throws Error: ... invalid ELF header or “was compiled against a different Node.js version”. The same happens with a multi-stage build that installs on Debian and copies node_modules into an Alpine runtime: right CPU, wrong libc. Fixes: exclude node_modules in .dockerignore, install dependencies inside the build for the target platform, and keep build and runtime stages on the same distribution family.
Building for several platforms
docker buildx build --platform linux/amd64,linux/arm64 builds every platform and pushes an index. For each platform BuildKit runs the Dockerfile with the target’s base image; for foreign platforms, RUN steps go through QEMU. That’s correct for everything, including native addons, and often painfully slow: compiling C extensions under emulation can be 5 to 20 times slower than natively.
The faster pattern runs the compiler natively and tells it the target. BuildKit sets platform arguments automatically:
FROM --platform=$BUILDPLATFORM node:22-bookworm AS build
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
RUN echo "built on $BUILDPLATFORM for $TARGETPLATFORM (GOOS=$TARGETOS GOARCH=$TARGETARCH)" > /info.txt
FROM scratch
COPY --from=build /info.txt /info.txt$ docker build --platform linux/amd64,linux/arm64 -t arch-demo .
#5 [linux/arm64 build 2/2] RUN echo "built on linux/arm64 for linux/arm64 (GOOS=linux GOARCH=arm64)" > /info.txt
#6 [linux/arm64->amd64 build 2/2] RUN echo "built on linux/arm64 for linux/amd64 (GOOS=linux GOARCH=amd64)" > /info.txt
$ docker image ls --tree
arch-demo:latest 78b1a91405c1 14.2kB 6.05kB
├─ linux/amd64 7425c63d162f 1.07kB 1.07kB
└─ linux/arm64 577844c02cd2 9.26kB 1.07kBBoth build stages ran natively on arm64 (linux/arm64->amd64 in the log means “native builder producing for amd64”), and the result is one tag with two variants. In a real Dockerfile the RUN line would be GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ., which Go does natively. Rust with cross, Zig, .NET and Java (bytecode is portable) fit the same pattern. For C toolchains and most native npm or pip packages, cross-compiling is harder, so teams either accept emulation or use native builders: BuildKit can drive several nodes (docker buildx create --append with an arm64 node and an amd64 node) and send each platform to the matching machine. CI providers now offer arm64 runners for exactly this.
Note
Producing a multi-platform image locally needs the containerd image store (the default in recent Docker Desktop) or pushing straight to a registry with
--push. The classic image store can hold only one platform per tag.
Interview practice
Your team moves CI to arm64 runners to save money. The next release of a Python API, based on python:3.12-slim, deploys to the existing x86 Kubernetes cluster and every pod fails with exec /usr/local/bin/python3: exec format error. Someone suggests adding --platform linux/amd64 to the build. Explain the failure, evaluate that fix, and propose a better setup.
Answer
The arm64 runners built single-platform arm64 images, since a build defaults to the builder’s architecture. The x86 nodes pulled them, and the kernel couldn’t execute the arm64 Python interpreter, hence exec format error on the very first binary.
Adding --platform linux/amd64 works, but on an arm64 runner it means every RUN step runs under QEMU emulation. pip install of packages with C extensions that lack prebuilt wheels compiles under emulation, which can turn a 3-minute build into a 30-minute one, and you’ve lost the cost saving.
Better: build --platform linux/amd64,linux/arm64 and push an index, so the image works on both clusters and on developer Macs. Use native builders per platform (an amd64 runner or a remote amd64 BuildKit node alongside the arm64 one) so nothing is emulated, or accept emulation only for the amd64 variant if prebuilt wheels make it cheap. Add a CI check that inspects the pushed manifest and fails if a required platform is missing, and run smoke tests on each architecture before deploying.