You build on an Apple Silicon Mac. You push. The server says:
exec /app/entrypoint: exec format errorNothing is corrupt. You built an arm64 image and the server is amd64,
and a CPU cannot execute instructions from another architecture.
Images have an architecture
An image is built for a specific OS and CPU, and the default is whatever built it:
$ docker info --format '{{.Architecture}}'
aarch64
$ docker image inspect pizza-web:dev --format '{{.Os}}/{{.Architecture}}'
linux/arm64That is the whole problem. Every image built on this machine is arm64 unless
something says otherwise, and most cloud instances are amd64.
It went unnoticed for years because everyone's laptop was x86. Apple Silicon and Graviton made it a daily concern.
Why you did not notice locally
$ docker run --rm --platform linux/amd64 alpine:3.21 uname -m
x86_64That runs, on an arm64 machine, because Docker Desktop emulates. Which is convenient and means you cannot catch this by running the image on your laptop — it works there and fails on the server.
Building for the other one
docker buildx build --platform linux/amd64 -t pizza-web:amd64 --load .buildx rather than build, because plain docker build
cannot target another platform. It is bundled with Docker Desktop and recent Engine versions:
$ docker buildx version
github.com/docker/buildx v0.19.2And for both at once:
docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/…/pizza-web:1.4.2 --push .⚠️ --push, not --load. A multi-platform build produces a
manifest list — an index pointing at one image per architecture — and your local image
store can only hold one image per tag. So --load fails on a two-platform build, and
that error is a common first stumble.
The payoff of a manifest list is that the reference just works: everyone pulls
pizza-web:1.4.2 and the registry hands each client the right one.
What emulation costs
Honest numbers rather than received wisdom, measured on the machine this track was written on (Docker Desktop, Apple Silicon). Identical CPU-bound work — 600 MB hashed — native and emulated:
linux/arm64 2690 ms
linux/amd64 3251 msAbout 1.2×. That is far below the 5–10× that older writing about QEMU reports, because
Docker Desktop on Apple Silicon can use Rosetta rather than QEMU for x86 emulation. On a Linux CI
runner using binfmt_misc and QEMU, expect much worse.
⚠️ And a warning about how you measure it. Timing two docker build --no-cache runs
does not measure emulation — it mostly measures npm and Maven downloading over the network. Doing
exactly that on the API image here produced 87 s native and 20 s emulated, which is nonsense.
Isolate the CPU work or do not quote a number.
The trick that removes the cost
Emulating the whole build is the brute-force approach. If your toolchain can cross-compile, you can run the build stage natively and only produce output for the target:
FROM golang:1.23-alpine AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY . .
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app .
FROM alpine:3.21
COPY /out/app /app
ENTRYPOINT ["/app"]Two variables BuildKit provides automatically. BUILDPLATFORM is the machine doing
the building; TARGETPLATFORM (and its TARGETOS/TARGETARCH
parts) is what is being built for. So the compiler runs at full native speed and is simply told to
emit a different architecture.
How far this gets you depends entirely on the language. Go and Rust cross-compile straightforwardly. The JVM does not need any of this — bytecode is architecture independent, so only the base image differs, and a JVM multi-platform build is nearly free. Interpreted languages are fine until a dependency has a native extension, and then you are back to emulation for that step.
Checking what you have
docker buildx imagetools inspect ghcr.io/…/pizza-web:1.4.2Lists every platform in the manifest, without pulling anything. Worth putting in a deploy checklist the first few times.
Which platforms to build
Not both by default. A multi-platform build costs roughly the sum of its parts in CI time, every time, and half of it may be for nothing.
Just linux/amd64 if that is what you deploy to and nobody runs the
image locally. Simplest and cheapest.
Both if developers on Apple Silicon run the same images your servers do — the usual reason, and worth the CI minutes for the friction it removes.
Just linux/arm64 if you have moved to Graviton or Ampere, which is
increasingly the cheaper choice.