Our M-series laptops built arm64, our CI built amd64, and prod pulled whichever tag won the race. Buildx and a manifest list ended the chaos.
The bug report said "works on my machine, crashes in prod." It really did. A developer on an M3 MacBook built and pushed app:latest, which was an arm64 image. Prod ran on amd64 EC2. The container refused to start with exec format error, which is Docker's way of saying you pushed the wrong architecture. We'd been lucky for months because CI usually pushed the amd64 build last and overwrote the laptop's. That afternoon the ordering flipped.
The fix is a single image tag that contains both architectures, and docker buildx produces exactly that using a manifest list. The client pulls whichever architecture matches its platform, automatically. No more races.
A normal image tag points at one image for one architecture. A manifest list (the OCI spec calls it an image index) is a small pointer object that says "for linux/amd64 use this digest, for linux/arm64 use that digest." When any Docker or Kubernetes node pulls the tag, the registry hands it the entry matching its own platform. The user does nothing. This is why alpine:3.20 runs everywhere without you thinking about it, it's a manifest list under the hood.
You need a buildx builder that uses the docker-container driver, because the default docker driver can't do multi-platform. Create one once:
docker buildx create --name multi --driver docker-container --use
docker buildx inspect --bootstrap
Then build both platforms and push in a single command. The --push is required for multi-arch, because a manifest list can't live in your local image store, it only exists in a registry.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag registry.acme.io/app:1.8.3 \
--push \
.
buildx emulates the non-native architecture with QEMU, which is transparent but slow. On our amd64 CI runners, building the arm64 layer under emulation took about 4x longer than native. For a Go service that compiles fast it didn't matter. For a Python image with pip install compiling native extensions under emulation, a build went from 90 seconds to over 6 minutes. If that hurts, read the native-runner section below.
Without a cache, every build recompiles everything for both architectures. Use registry-backed cache so cache survives across ephemeral CI runners.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=registry.acme.io/app:buildcache \
--cache-to type=registry,ref=registry.acme.io/app:buildcache,mode=max \
--tag registry.acme.io/app:1.8.3 \
--push \
.
mode=max caches intermediate layers too, not just the final ones, which is what you want for a multi-stage Dockerfile. This took our warm-cache CI build from 6 minutes back down to about 70 seconds even with the arm64 emulation, because unchanged layers came straight from the registry cache.
For the Python case, QEMU was unacceptable. The clean answer is building each architecture on native hardware and stitching the results into one manifest afterward. GitHub Actions now offers arm64 runners, so you build amd64 on an amd64 runner, arm64 on an arm64 runner, push each with a per-arch suffix, then create the manifest list by hand.
# on each native runner, push a per-arch tag by digest
docker buildx build --platform linux/amd64 \
--tag registry.acme.io/app:1.8.3-amd64 --push .
# in a final job, combine into one multi-arch tag
docker buildx imagetools create \
--tag registry.acme.io/app:1.8.3 \
registry.acme.io/app:1.8.3-amd64 \
registry.acme.io/app:1.8.3-arm64
imagetools create builds the manifest list from already-pushed images without rebuilding anything, so the combine step takes a couple of seconds. Native arm64 build of our Python image dropped to about 95 seconds, matching the amd64 build, and the two run in parallel.
Trust nothing. Inspect the pushed tag and confirm both platforms are present before you call it done.
docker buildx imagetools inspect registry.acme.io/app:1.8.3
You want to see two Platform: entries, linux/amd64 and linux/arm64. We added this as a CI assertion that greps the output and fails the pipeline if either architecture is missing, because a silently single-arch image is the exact bug we were trying to kill.
For fast-compiling languages like Go or Rust, use the simple one-command buildx build with --platform linux/amd64,linux/arm64 and registry cache. The emulation overhead is small and the pipeline stays a single job. For anything that compiles native extensions during the build (Python with C deps, some Node modules), spend the extra pipeline complexity on native runners plus imagetools create, because QEMU will otherwise triple your build time. Either way, add the imagetools inspect check so a single-arch image can never sneak to prod again.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
How we cut auth redirect latency to single-digit milliseconds and ran A/B tests without a flash of wrong content, using Vercel Edge Middleware.
Our node image shipped 240 CVEs, most from OS packages we never called. Moving to distroless dropped the count to single digits and cut image size by 70%.
Explore more articles in this category
We used to ship code and turn it on in the same breath, so every deploy was a bet. Feature flags split those two events apart and made rollbacks a config toggle.
Our best engineer quit citing on-call. We rebuilt the whole thing: saner rotations, runbooks that actually help at 3am, and escalation that doesn't punish asking for help.
Our early postmortems quietly assigned blame and taught people to hide mistakes. Here's the template and the facilitation rules that finally made them honest and useful.
Evergreen posts worth revisiting.