Cross-Platform Container Builds for Mixed Teams
When one half of the team develops on Apple Silicon and the other half on x86 laptops and CI runners, an image that builds cleanly on one architecture can fail to pull, crash on startup, or silently run under emulation on the other. This guide extends the broader containerized local environment patterns with a single reproducible workflow for building and running images across linux/arm64 and linux/amd64, so an M-series MacBook, an Intel desktop, and an AMD64 pipeline runner all resolve the same behaviour from the same docker-compose.yml.
The sections below follow the order the problem actually surfaces on a mixed team: you provision a builder that can target both architectures, you produce a multi-architecture image whose manifest lets each machine pull its own variant, you decide where native builds beat emulation, you pin Compose so parity is enforced rather than hoped for, and finally you cross-compile the stubborn native dependencies that emulation makes slow. Each section ends with a diagnostic command you can wire into a pre-merge check so an architecture regression exits non-zero before it reaches a teammate's laptop.
Prerequisites
- Docker Desktop 4.30+ on macOS or Windows, or Docker Engine 27+ with the Buildx plugin on Linux. Confirm with
docker buildx version— it must reportv0.14or newer for the--attestand named-context flags used below. - Compose v2 (
docker compose versionreportsv2.x). The legacydocker-composev1 binary ignores theplatform:key and thedevelop.watchblock, which produces a config that parses but behaves differently per machine. - The
binfmt_mischandlers registered in the kernel so an AMD64 host can execute ARM64 binaries and vice versa. Docker Desktop ships these; on native Linux you install them once with a privileged helper image, shown in Section 1. - A container registry you can push to (Docker Hub, GHCR, ECR, or a local
registry:2). Multi-architecture manifests only fully materialise when pushed, so a registry is not optional for the team-parity workflow. jqfor inspecting manifest JSON, and a repository whosedocker-compose.ymlandDockerfileare checked into version control.
Verify the toolchain resolves before layering anything on top. Run docker buildx ls and confirm at least the default docker builder is present; if you have never used Buildx, that builder cannot produce multi-platform output on its own, which is exactly the gap Section 1 closes. If your team has not yet standardised the base image and lifecycle, pair this guide with devcontainer configuration standards so the pinned base you build for two architectures is the same one every workstation resolves.
Section 1 - Provision a buildx builder that targets both architectures
The default builder that ships with Docker uses the host's native docker driver, which can only emit an image for the architecture it runs on. To produce linux/arm64 and linux/amd64 from a single command you need a builder backed by the docker-container driver, which runs BuildKit in a helper container and can drive multiple platform nodes — including QEMU-emulated ones. On native Linux you also register the cross-architecture emulation handlers first; Docker Desktop registers them for you.
- Register the QEMU emulation handlers (Linux hosts only — skip on Docker Desktop). The
tonistiigi/binfmtimage installsbinfmt_miscentries so the kernel transparently runs foreign-architecture binaries through QEMU. - Create a dedicated builder with the
docker-containerdriver and bootstrap it so the BuildKit container is pulled and started before the first build. - Confirm the builder advertises both platforms in its node list — this is the check that proves emulation is wired up correctly.
- Select the builder for the current shell so subsequent
docker buildx buildcalls use it by default.
#!/usr/bin/env bash
set -euo pipefail
# 1. Register QEMU handlers (Linux only; Docker Desktop already has them).
if [ "$(uname -s)" = "Linux" ]; then
docker run --privileged --rm tonistiigi/binfmt --install arm64,amd64
fi
# 2. Create a container-driver builder that can host multiple platform nodes.
docker buildx create \
--name xplat \
--driver docker-container \
--bootstrap \
--use
# 3 & 4. Show the platforms this builder can target.
docker buildx inspect xplat --bootstrap | grep -i platforms
The --bootstrap flag forces BuildKit to start immediately so failures surface here rather than mid-build. After this runs, docker buildx ls shows the xplat builder with a running node whose Platforms line includes both linux/amd64 and linux/arm64 (plus emulated variants like linux/arm/v7).
Two properties of this builder matter for a mixed team. First, the docker-container driver keeps its own build cache separate from the host daemon's image store, which is why a multi-platform result cannot simply appear as a local image — it lives in BuildKit until you export it. Second, the builder is a shared, reproducible artefact: check the exact create command into a Makefile target or a bootstrap script so every developer provisions an identical builder rather than each person discovering the flags by trial and error. A builder that one engineer configured with Rosetta and another left on plain QEMU is itself a source of the "slow on my machine" reports this workflow is meant to eliminate, so treat the builder definition as code that lives beside the Compose file.
Apple Silicon (ARM64): Docker Desktop runs its Linux VM natively as
linux/arm64. Buildinglinux/amd64from an M-series Mac goes through Rosetta or QEMU; enable "Use Rosetta for x86/amd64 emulation" in Docker Desktop settings for markedly faster AMD64 emulation than plain QEMU.
Drift diagnostic. Fail a pre-merge check when the builder cannot target both architectures:
#!/usr/bin/env bash
set -euo pipefail
required="linux/amd64 linux/arm64"
have="$(docker buildx inspect xplat --bootstrap | awk -F': ' '/Platforms/{print $2}')"
for p in $required; do
case "$have" in
*"$p"*) : ;;
*) echo "builder cannot target $p (has: $have)"; exit 1 ;;
esac
done
echo "builder targets both architectures"
Section 2 - Build a multi-architecture image with a manifest list
A multi-architecture image is not one binary that runs everywhere; it is a manifest list (an OCI image index) that maps each platform to its own image digest. When a machine runs docker pull, the daemon reads the list and fetches only the variant matching its own architecture. That indirection is what lets an Apple Silicon laptop and an AMD64 runner share one image tag and each get a native binary — but it only fully exists once the list is pushed, because the local image store keeps a single-architecture image per tag.
- Pass both platforms to a single build with
--platform linux/amd64,linux/arm64. BuildKit fans out one build graph per platform on the multi-node builder. - Push directly to a registry with
--push. A manifest list cannot be--loaded into the local Docker image store, which only holds one architecture per tag, so--push(or--output type=oci) is required to materialise the list. - Attach provenance and SBOM attestations with
--provenanceand--sbomso each variant carries a verifiable record of how it was built — cheap supply-chain hygiene that costs one flag each. - Inspect the pushed manifest to confirm both architectures are present under one tag.
#!/usr/bin/env bash
set -euo pipefail
IMAGE="ghcr.io/acme/api:1.4.0"
docker buildx build \
--builder xplat \
--platform linux/amd64,linux/arm64 \
--provenance=true \
--sbom=true \
--tag "$IMAGE" \
--push \
.
# Confirm the tag resolves to a two-architecture manifest list.
docker buildx imagetools inspect "$IMAGE"
The imagetools inspect output lists a Manifest block per platform, each with its own digest and platform.architecture of amd64 and arm64. To assert that in a script rather than by eye, parse the raw index:
Drift diagnostic. Fail if a required architecture is missing from the pushed tag:
#!/usr/bin/env bash
set -euo pipefail
IMAGE="ghcr.io/acme/api:1.4.0"
archs="$(docker buildx imagetools inspect "$IMAGE" --raw \
| jq -r '.manifests[].platform.architecture' | sort -u | tr '\n' ' ')"
for want in amd64 arm64; do
case "$archs" in
*"$want"*) : ;;
*) echo "manifest missing $want (has: $archs)"; exit 1 ;;
esac
done
echo "manifest list covers: $archs"
Wiring this diagnostic into the same pipeline that runs your CI/CD pipeline parity checks turns "the image is multi-arch" from a claim into a gate — a single-architecture image published by accident fails the build instead of surfacing as a teammate's exec format error two days later.
Section 3 - Decide where native builds beat emulation
Emulation makes cross-building possible, but it is not free. QEMU translates every foreign instruction, so an ARM64 build of a compile-heavy image on an AMD64 host can run five to twenty times slower than native, and occasionally trips over syscalls a JIT or a native test suite exercises. The correct default for a mixed team is: emulate for convenience during local iteration, but build each architecture natively in CI on a matching runner when build time or correctness matters. The comparison below frames the trade-off, and the measured build times make the cost concrete.
The two strategies compose rather than compete. A common pattern is a CI matrix with one job per architecture on a matching runner, each producing a per-platform image, and a final job that stitches the digests into one manifest list with docker buildx imagetools create. That gives native speed and correctness without asking every developer to own two machines. The numbers below are representative rebuild times for a Node service with a native bcrypt addon; treat them as an order-of-magnitude guide, since your compile mix dominates.
The decision hinges on where the time actually goes. If your image is mostly COPY and dependency-download layers with little native compilation, emulation adds only a modest overhead and a single emulated job is the pragmatic choice — the extra infrastructure of native runners buys little. If instead the image compiles a substantial amount of C, C++, Rust, or Go, the emulated variant can dominate the entire pipeline, and a native runner pays for itself immediately. Measure once with the time wrapper below rather than guessing, because the answer flips depending on the workload and re-running the measurement after a dependency change keeps the decision honest as the image evolves.
- Measure the emulated build on your own image before optimising, so the decision is data-driven rather than folkloric.
- Split CI into a per-architecture matrix when the emulated arm64 job exceeds your pipeline budget.
- Merge per-arch digests into one tag as a final step, keeping the manifest-list guarantee from Section 2.
#!/usr/bin/env bash
set -euo pipefail
IMAGE="ghcr.io/acme/api:1.4.0"
# Native per-arch builds (run each on a matching runner, pushed by digest).
AMD_DIGEST="$(docker buildx build --platform linux/amd64 \
--output "type=image,name=$IMAGE,push-by-digest=true,name-canonical=true" \
--metadata-file /tmp/amd.json . && jq -r '."containerimage.digest"' /tmp/amd.json)"
ARM_DIGEST="$(docker buildx build --platform linux/arm64 \
--output "type=image,name=$IMAGE,push-by-digest=true,name-canonical=true" \
--metadata-file /tmp/arm.json . && jq -r '."containerimage.digest"' /tmp/arm.json)"
# Stitch both native digests into one manifest list under the release tag.
docker buildx imagetools create --tag "$IMAGE" \
"${IMAGE%:*}@${AMD_DIGEST}" "${IMAGE%:*}@${ARM_DIGEST}"
Section 4 - Enforce architecture parity in Compose
A build that produces the right manifest is only half the parity story; the run side has to resolve the right variant on every machine and fail loudly when it cannot. Compose reads the platform of the host by default, which is usually what you want, but two failure modes bite mixed teams. First, a base image that publishes only amd64 will silently run under emulation on Apple Silicon, turning a "fast" service into a slow one nobody profiles. Second, a floating tag can drift between architectures if a maintainer republishes one variant. Pin digests and make the platform explicit where correctness depends on it. This is the same reproducibility contract that multi-service orchestration with Compose applies to service ordering, extended to the architecture axis.
- Pin third-party images by digest, not by floating tag, so the arm64 and amd64 variants a teammate pulls are exactly the ones you tested.
- Set
platform:explicitly only where you must — for a service that lacks a native arm64 build and must run emulated, declareplatform: linux/amd64so the emulation is intentional and visible in the file rather than a surprise. - Read the build platform from the host via the default, and expose a build arg for images you build yourself so a developer can force a cross-build locally without editing the file.
- Detect accidental emulation at runtime by comparing the container's architecture to the host's.
services:
api:
build:
context: .
# BUILDPLATFORM defaults to the host; override for a local cross-build.
args:
TARGETARCH: ${TARGETARCH:-}
image: ghcr.io/acme/api:1.4.0
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
db:
# Digest-pinned so every architecture resolves an identical, tested image.
image: postgres:16.3@sha256:0c1c2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9
environment:
POSTGRES_PASSWORD: localdev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
legacy-tool:
# This vendor image ships amd64 only; make the emulation explicit.
image: vendor/legacy-cli:2.9
platform: linux/amd64
volumes:
db-data:
WSL2: Compose reads the architecture of the WSL2 VM, which is
amd64on an x86 PC andarm64on a Windows-on-ARM device. A digest pinned only foramd64fails to pull inside an arm64 WSL2 distro withno matching manifest, so pin multi-arch base images.
Drift diagnostic. Detect a service running under emulation — a container whose architecture does not match the host is almost always an unintended emulation cost:
#!/usr/bin/env bash
set -euo pipefail
host="$(docker info --format '{{.Architecture}}')"
docker compose ps --format '{{.Name}}' | while read -r c; do
[ -n "$c" ] || continue
carch="$(docker inspect --format '{{.Architecture}}' "$c" 2>/dev/null || echo unknown)"
if [ "$carch" != "$host" ] && [ "$carch" != "unknown" ]; then
echo "WARN: $c runs $carch on a $host host (emulated)"
fi
done
echo "architecture parity check complete"
Section 5 - Cross-compile native dependencies with build stages
The images that suffer most under emulation are the ones with native compilation steps — Go binaries, Rust crates, Node addons, Python wheels with C extensions. BuildKit exposes the automatic platform args BUILDPLATFORM (the builder's own architecture) and TARGETPLATFORM (the architecture being produced), which let you compile on the fast native builder and copy the result into the target image. For a statically linked language like Go this eliminates emulation entirely: the compiler runs natively and cross-compiles to the target, so an arm64 image is built at amd64 speed. Where a native toolchain cannot cross-compile cleanly, keep the target-architecture build stage minimal so only the smallest possible surface runs emulated.
- Declare a build stage
FROM --platform=$BUILDPLATFORMso the compiler runs natively on the builder regardless of the target. - Map
TARGETARCHto your toolchain's target flag (GOARCH, Rust target triple, or the wheel platform tag) so one Dockerfile produces every architecture. - Copy only the built artefact into a clean
FROM $TARGETPLATFORMruntime stage, keeping the emulated surface near zero. - Verify the produced binary's architecture matches the target before shipping the layer.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Compiler runs natively on the builder; cross-compiles to the target arch.
RUN \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -o /out/api ./cmd/api
FROM gcr.io/distroless/static:nonroot AS runtime
COPY /out/api /usr/local/bin/api
USER nonroot
ENTRYPOINT ["/usr/local/bin/api"]
The mechanism is worth internalising because it generalises beyond Go. BUILDPLATFORM and TARGETPLATFORM are injected by BuildKit into every build, and the derived TARGETOS, TARGETARCH, and TARGETVARIANT args are available in any stage that declares them with ARG. The pattern is always the same: pin the compile-heavy stage to the fast native builder, translate the target args into whatever flag your toolchain uses to emit foreign-architecture output, and let only a thin runtime stage be produced per architecture. A Rust build sets the target triple from TARGETARCH; a Python image selects a prebuilt manylinux wheel keyed on the target; a Node image downloads the matching prebuilt addon instead of recompiling it. In each case the emulated surface shrinks from "the whole build" to "a file copy," which is where the order-of-magnitude speedups in the chart above come from.
Because the build stage is pinned to $BUILDPLATFORM, docker buildx build --platform linux/amd64,linux/arm64 compiles both variants on the native builder and never invokes QEMU for the Go toolchain — only the tiny distroless runtime layer is per-architecture, and it contains no executable build steps. If your image instead depends on prebuilt native modules (a common Node case), fetching a target-architecture wheel or prebuilt binary keyed on TARGETARCH is the same idea applied to a download rather than a compile, and it pairs well with the caching discipline in optimizing Docker Compose for fast local rebuilds.
Drift diagnostic. Confirm the compiled artefact is actually the target architecture, catching a stray native-host build that skipped the cross-compile:
#!/usr/bin/env bash
set -euo pipefail
IMAGE="ghcr.io/acme/api:1.4.0"
for arch in amd64 arm64; do
got="$(docker run --rm --platform "linux/$arch" --entrypoint /bin/sh \
"$IMAGE" -c 'echo ok' 2>/dev/null && \
docker image inspect "$IMAGE" --format '{{.Architecture}}')"
echo "linux/$arch pull: reported ${got:-failed}"
done
Platform caveats
Cross-architecture behaviour differs enough between host operating systems that a workflow proven on one can fail on another. Group the machine-specific gotchas here so a teammate on any platform can find their case.
Apple Silicon (ARM64): The Docker Desktop VM is native arm64, so an unqualified
docker buildproduces an arm64 image that an x86 CI runner cannot execute — the classicexec format error. Always pass--platformor build the multi-arch manifest from Section 2. Enabling Rosetta in Docker Desktop's settings makes emulated amd64 builds several times faster than plain QEMU.
macOS (Docker Desktop): Bind-mounted source crosses the VirtioFS boundary, and a cross-architecture build that recompiles native modules on every rebuild compounds that latency. Combine the cross-compile stage from Section 5 with a BuildKit cache mount so the compile cost is paid once, not per rebuild. Volume-path specifics are covered in fixing volume permission issues on macOS and Windows.
WSL2: The QEMU
binfmt_mischandlers register inside the WSL2 VM, not the Windows host, and they reset when the VM restarts. If a cross-build suddenly reportsexec format errorafter a reboot, re-run thetonistiigi/binfmt --installstep from Section 1 inside your distro. Keep the Docker daemon and your repository on the Linux filesystem (/home/...), not/mnt/c, or build performance collapses.
Native Linux (Docker Engine): Unlike Docker Desktop, Engine does not ship the QEMU handlers, so Section 1's
binfmtinstall is mandatory before any multi-platform build. The handlers persist until reboot; add the install command to a boot unit if your CI runners are long-lived hosts rather than ephemeral VMs.
Rollback and recovery
Every change in this guide is reversible without touching a teammate's machine, because the durable state lives in the registry manifest and the Compose file rather than in local daemon configuration.
- Undo the builder. Remove the container-driver builder and fall back to the default:
docker buildx rm xplat && docker buildx use default. This deletes only the BuildKit helper container and its cache; no images are lost. - Revert a bad multi-arch tag. Manifest lists are immutable per digest, so repoint the tag at a known-good digest with
docker buildx imagetools create --tag ghcr.io/acme/api:1.4.0 ghcr.io/acme/api@sha256:<good-digest>. Consumers pulling the tag get the previous image on their next pull. - Disable emulation quickly. If QEMU builds behave incorrectly, drop the emulated platform from the build command and ship only the native architecture temporarily:
docker buildx build --platform linux/amd64 --tag ghcr.io/acme/api:1.4.0-hotfix --push .. Communicate that the arm64 variant is temporarily absent so teammates pin the last good tag. - Clear stale binfmt handlers. On Linux,
docker run --privileged --rm tonistiigi/binfmt --uninstall qemu-*removes the handlers if a partial registration is causingexec format error; reinstall cleanly with the Section 1 command.
Because none of these steps mutate a developer's checkout, recovery is a registry and CLI operation — no one has to re-clone or rebuild their workstation to get back to a working baseline. Pair this with the drift detection in the CI parity validation reference so a regressed manifest is caught in the pipeline before it becomes a rollback.
Frequently Asked Questions
Why does my image fail with exec format error on the CI runner but works on my Mac?
Your Apple Silicon machine built a linux/arm64 image and pushed it under a tag the AMD64 runner then pulled and tried to execute natively. The kernel cannot run arm64 instructions on an amd64 CPU, so it reports exec format error. Fix it by building a multi-architecture manifest with docker buildx build --platform linux/amd64,linux/arm64 --push, which publishes both variants under one tag so each host pulls the one it can run.
Can I load a multi-architecture image into my local Docker with --load?
No. The local Docker image store holds a single architecture per tag, so --load rejects a multi-platform build. Use --push to send the manifest list to a registry, or build a single platform with --platform linux/arm64 --load for local testing. To inspect a multi-arch image without pulling it, run docker buildx imagetools inspect <image>.
Is QEMU emulation accurate enough to run my test suite during a cross-build?
For most application code, yes — QEMU faithfully emulates the instruction set. The risk is at the edges: JITs, timing-sensitive tests, and rare syscalls can behave differently or crash under emulation, and the slowdown can push timeouts. For release builds run the test suite natively on a matching-architecture runner; reserve emulation for a fast local iteration loop where a green run is a smoke test, not a guarantee.
Do I need a separate runner for each architecture, or can one machine build both?
One machine can build both through emulation, which is the simplest setup and fine when build times stay within your pipeline budget. Add a native runner per architecture only when the emulated build is too slow or when you need native-speed tests — then build each variant on its own runner and merge the digests with docker buildx imagetools create into one manifest list, keeping the single-tag guarantee.