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 report v0.14 or newer for the --attest and named-context flags used below.
  • Compose v2 (docker compose version reports v2.x). The legacy docker-compose v1 binary ignores the platform: key and the develop.watch block, which produces a config that parses but behaves differently per machine.
  • The binfmt_misc handlers 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.
  • jq for inspecting manifest JSON, and a repository whose docker-compose.yml and Dockerfile are 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.

  1. Register the QEMU emulation handlers (Linux hosts only — skip on Docker Desktop). The tonistiigi/binfmt image installs binfmt_misc entries so the kernel transparently runs foreign-architecture binaries through QEMU.
  2. Create a dedicated builder with the docker-container driver and bootstrap it so the BuildKit container is pulled and started before the first build.
  3. Confirm the builder advertises both platforms in its node list — this is the check that proves emulation is wired up correctly.
  4. Select the builder for the current shell so subsequent docker buildx build calls 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. Building linux/amd64 from 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"
Buildx multi-platform build pipeline A Dockerfile feeds a buildx container builder that emits an AMD64 and an ARM64 image, which are combined into one manifest list pushed to the registry. One Build, Two Architectures Dockerfile single source buildx builder docker-container amd64 image native or QEMU arm64 image native or QEMU registry manifest list
A single Dockerfile drives the buildx builder, which emits both architecture variants and combines them into one manifest list at the registry.

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.

  1. 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.
  2. 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.
  3. Attach provenance and SBOM attestations with --provenance and --sbom so each variant carries a verifiable record of how it was built — cheap supply-chain hygiene that costs one flag each.
  4. 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.

Manifest list resolves per pulling host A single image tag points to a manifest list holding two platform manifests; an ARM64 laptop pulls the arm64 digest while an AMD64 runner pulls the amd64 digest. One Tag, Per-Host Resolution api:1.4.0 manifest list (index) amd64 manifest sha256:a1… digest arm64 manifest sha256:b7… digest x86 CI runner pulls this Apple Silicon pulls this
The manifest list is an index; each host reads it and pulls only the digest that matches its own architecture, so one tag serves both teams.

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.

Native build node versus QEMU emulation Comparison of native cross-architecture runners against single-host QEMU emulation across speed, correctness, and setup cost. Native Runners vs QEMU Emulation Native runner per arch Full-speed compile + tests Exercises real syscalls Needs arm64 + amd64 hosts Best for CI release builds Use with cache export QEMU on one host 5–20x slower on compile Rare syscall edge cases Zero extra infrastructure Best for local iteration Rosetta speeds amd64
Emulate for a fast local loop; build natively per architecture in CI when compile time or syscall fidelity is on the line.

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.

  1. Measure the emulated build on your own image before optimising, so the decision is data-driven rather than folkloric.
  2. Split CI into a per-architecture matrix when the emulated arm64 job exceeds your pipeline budget.
  3. 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}"
Rebuild time by build strategy Bar chart comparing rebuild seconds for a native amd64 build, an emulated arm64 build, and a native arm64 build. Rebuild Time by Strategy (seconds) amd64 native 42s arm64 QEMU 6m 40s arm64 native 47s
Emulating the ARM64 build of a compile-heavy image costs roughly 9x the native time here — the case for a native runner in CI.

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.

  1. 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.
  2. Set platform: explicitly only where you must — for a service that lacks a native arm64 build and must run emulated, declare platform: linux/amd64 so the emulation is intentional and visible in the file rather than a surprise.
  3. 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.
  4. 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 amd64 on an x86 PC and arm64 on a Windows-on-ARM device. A digest pinned only for amd64 fails to pull inside an arm64 WSL2 distro with no 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.

  1. Declare a build stage FROM --platform=$BUILDPLATFORM so the compiler runs natively on the builder regardless of the target.
  2. Map TARGETARCH to your toolchain's target flag (GOARCH, Rust target triple, or the wheel platform tag) so one Dockerfile produces every architecture.
  3. Copy only the built artefact into a clean FROM $TARGETPLATFORM runtime stage, keeping the emulated surface near zero.
  4. Verify the produced binary's architecture matches the target before shipping the layer.
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM 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 --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build -trimpath -o /out/api ./cmd/api

FROM gcr.io/distroless/static:nonroot AS runtime
COPY --from=build /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
Choosing a cross-platform build strategy A decision path: if the toolchain can cross-compile, build natively on the builder; otherwise minimise the emulated stage or use a native CI runner. Which Build Strategy? Can the toolchain cross-compile natively? Yes — Go, Rust, static FROM --platform=$BUILDPLATFORM compile once, no QEMU No — heavy C extensions native CI runner per arch or minimise emulated stage yes no
Cross-compile on the native builder when the toolchain allows it; fall back to a native per-architecture runner only for dependencies that resist cross-compilation.

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 build produces an arm64 image that an x86 CI runner cannot execute — the classic exec format error. Always pass --platform or 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_misc handlers register inside the WSL2 VM, not the Windows host, and they reset when the VM restarts. If a cross-build suddenly reports exec format error after a reboot, re-run the tonistiigi/binfmt --install step 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 binfmt install 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 causing exec 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.