If your container runs fine locally on an Apple Silicon Mac and dies on the server with exec /usr/local/bin/app: exec format error, you built an arm64 image and deployed it to an amd64 host. The fix is not a flag on docker run — you need a multi-architecture image, built either by cross-compiling inside the Dockerfile or by building each architecture on its own native machine and merging the results into one manifest. Emulation via QEMU will also work, and it is the slowest option by a wide margin on compile-heavy images.
Why does exec format error only show up on the server?
docker build produces an image for the architecture of whatever machine ran the build, unless you tell it otherwise. On an M-series Mac that is linux/arm64. Your EC2 box, your Hetzner VPS, most CI runners, and most managed container platforms are still linux/amd64. The kernel on that host loads your ELF binary, reads a machine type it cannot execute, and returns ENOEXEC. Docker surfaces that as exec format error, which reads like a corrupt binary and is actually a passport problem.
You can confirm the mismatch in about ten seconds:
# What did I actually build?
docker image inspect myapp:latest --format '{{.Os}}/{{.Architecture}}'
# What architectures does the pushed tag serve?
docker buildx imagetools inspect ghcr.io/me/myapp:latest
If the second command lists only one platform, every host that isn't that platform is one deploy away from the same error. The related failure you'll hit on the way is no matching manifest for linux/amd64 in the manifest list entries — that is the same problem caught earlier, at pull time instead of exec time, which is strictly better.
There's also a quieter variant. Run an amd64 image on an Apple Silicon Mac and Docker prints WARNING: The requested image's platform (linux/amd64) does not match the detected host platform and then runs it anyway under emulation. That warning is the one people learn to scroll past, and it is exactly the signal that your local and production architectures have drifted apart.
Takeaway: exec format error is never a corrupted binary — it means the image's architecture and the host's architecture disagree, and one imagetools inspect proves which side is wrong.
What are the three ways to build for both architectures?
| Approach | arm64 + amd64 build speed | Setup cost | Works with CGO / native deps | Best when |
|---|---|---|---|---|
| QEMU emulation on one runner | Slowest; the emulated half dominates the build | One extra action, no Dockerfile changes | Yes, but painfully slow | Interpreted apps, images you rebuild rarely |
| Cross-compile in the Dockerfile | Fast — no emulation at all | Dockerfile rework | No (needs a cross toolchain) | Go and Rust services |
| Native runner per architecture, then merge manifests | Fast, and both halves run in parallel | Two jobs plus a merge job | Yes | Anything, including Python wheels and node-gyp |
A fourth option is to rent someone else's native builders. If you want a managed version of the two-runner setup without maintaining the matrix yourself, Depot runs native amd64 and arm64 builders behind a single docker build call and keeps the layer cache on persistent volumes between runs; the trade-off is that your build context now leaves your CI provider for a third party, which some compliance reviews will care about. Docker Build Cloud is the equivalent from Docker itself and has the advantage of reusing your existing Docker Hub identity, with the caveat that its pricing is metered in build minutes, so a repo with a noisy push trigger can spend more than you expect.
Takeaway: emulation is the cheapest to configure and the most expensive to run, so treat it as a stopgap rather than a resting place.
How do I set up buildx and QEMU correctly?
The single-runner path needs the QEMU binfmt handlers registered and a buildx builder that uses the docker-container driver — the default docker driver cannot build more than one platform at a time:
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --name multi --driver docker-container --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/me/myapp:1.4.0 \
--push .
The thing that trips people up here: you cannot --load a multi-platform build into your local image store. Docker's classic image store holds one manifest per tag, so --load with two platforms fails outright. Either push to a registry (as above), or build a single platform locally for testing with --platform linux/amd64 --load. As of mid-2026 the containerd image store in Docker Desktop removes this limitation, but it's an opt-in setting, so don't write a team runbook that assumes it.
In GitHub Actions the equivalent is two setup steps before the build:
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/${{ github.repository }}:latest
Takeaway: --platform with two values and --load are mutually exclusive on the classic image store — push to a registry, or build one platform at a time locally.
How do I stop the emulated half from dominating build time?
For compiled languages, skip emulation entirely. BuildKit hands you BUILDPLATFORM and TARGETARCH; pin the build stage to the native platform and let the compiler do the cross-targeting:
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -o /out/app ./cmd/app
FROM alpine:3.20
COPY --from=build /out/app /usr/local/bin/app
ENTRYPOINT ["/usr/local/bin/app"]
Every stage now runs natively; only the tiny final COPY differs per architecture. The honest limitation is CGO_ENABLED=0: the moment you need cgo, sqlite bindings, or any C library, you're back to installing a cross toolchain, and that is usually more work than the third option below.
For everything else — Python with compiled wheels, Node with native addons, anything with a make install that assumes a real compiler — build each architecture on a machine that actually is that architecture, then stitch the digests together. As of mid-2026 GitHub offers hosted Linux arm64 runners under labels like ubuntu-24.04-arm, which makes this pattern available without self-hosting:
jobs:
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v6
with:
platforms: ${{ matrix.platform }}
outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
- run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
merge:
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t ghcr.io/${{ github.repository }}:latest \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
The two build jobs run in parallel, so wall-clock time is roughly the slower of the two native builds rather than the sum of a native build and an emulated one. What tripped me up the first time: the merge job needs its own registry login, because imagetools create reads the source digests from the registry rather than from any local state.
Takeaway: parallel native builds plus a manifest merge is the only approach that is both fast and language-agnostic, and it costs you one extra CI job.
Which one should you actually pick?
Start from what breaks if you choose wrong. If your image is a Go or Rust binary, cross-compiling in the Dockerfile is the smallest change with the biggest win, and you can do it in one commit. If your build installs native dependencies, go straight to the matrix-plus-merge workflow — you will fight the cross toolchain for longer than it takes to write the merge job. Reach for emulation when the image is a thin layer over an interpreter and you rebuild it a few times a week; the build being slow simply won't matter at that frequency.
And regardless of approach, make the mismatch impossible to ship. Adding a pull-time architecture assertion to your deploy script catches the problem before a container ever restarts in a loop:
set -euo pipefail
want="linux/amd64"
docker buildx imagetools inspect "$IMAGE" | grep -q "$want" \
|| { echo "image $IMAGE has no $want variant"; exit 1; }
Takeaway: pick the approach by whether your build needs a C compiler, then verify the published manifest in CI so architecture drift fails the pipeline instead of the deploy.
FAQ
What does exec /usr/local/bin/app: exec format error mean in Docker?
It means the binary inside the image was compiled for a different CPU architecture than the host running the container — almost always an arm64 image built on an Apple Silicon Mac being run on an amd64 server. Rebuild the image with docker buildx build --platform linux/amd64 or publish a multi-architecture manifest covering both.
Can I build a multi-platform Docker image and load it into my local Docker?
Not with the classic image store: docker buildx build --platform linux/amd64,linux/arm64 --load fails because a local tag can only point at one manifest. Push to a registry instead, or build a single platform with --load for local testing. Docker Desktop's containerd image store lifts this restriction, but it is opt-in as of mid-2026.
Is --platform linux/amd64 on an M1 Mac enough for production?
It produces a correct amd64 image, but it runs your whole build under QEMU emulation, which is dramatically slower for anything that compiles code and occasionally exposes emulator bugs in native toolchains. It's fine as a one-off; for CI, build each architecture on a native runner and merge the manifests.
Top comments (0)