---
title: "Multi-architecture Docker builds: QEMU versus native ARM"
description: "Compare native ARM, QEMU, and Go cross-compilation with measured build times, a working Dockerfile, and reproducible evidence."
url: https://9apes.com/blog/multiarch-docker-builds-native-arm/
---

# Multi-architecture Docker builds: QEMU versus native ARM

Our small Go image took a median of 141.37 seconds to build for ARM through QEMU,
13.60 seconds on native ARM, and 17.41 seconds through cross-compilation on x86.
We ran five uncached builds per configuration and verified every resulting
executable on matching hardware.

## Measured build times

All 20 builds succeeded. The workload was a small Go command with one dependency
and CGO disabled.

| Configuration | Median (seconds) | Range (seconds) |
|---|---:|---|
| Native amd64 | 18.52 | 18.27 to 18.78 |
| Native arm64 | 13.60 | 12.99 to 14.25 |
| QEMU arm64 on amd64 | 141.37 | 139.90 to 147.04 |
| Cross-compiled arm64 on amd64 | 17.41 | 17.32 to 19.17 |

Cross-compilation finished about 124 seconds sooner than QEMU at the median.
The native amd64 row provides an x86 baseline; the other three rows produce ARM
images. The [evidence download](/downloads/ci-lab-multiarch-evidence-2026-09-16.zip)
contains all individual timings, version records, source files and image archives.

Each build used a fresh BuildKit builder with `--no-cache --pull`. Timing covered
base-image and dependency downloads, compilation, and OCI export. It excluded
builder startup, QEMU setup, queue time, artifact transfers and validation. Five
samples shared one hosted VM per configuration, so these are repeated builds,
not five independently allocated machines.

The hardware also differed. Native amd64 and cross-compilation used two visible
vCPUs on AMD EPYC 7763 machines; QEMU used two on an EPYC 9V74. Native ARM used
two visible Neoverse-N2 cores. These results compare the tested configurations
and cannot isolate QEMU overhead from hardware differences or predict results
for a larger application.

## Choose a build strategy

Docker supports [three multi-platform build strategies](https://docs.docker.com/build/building/multi-platform/):

| Strategy | Where compilation runs | When to consider it |
|---|---|---|
| QEMU | Target-architecture compiler under emulation | A portable build setup when the measured runtime is acceptable |
| Native builders | Hardware matching the target architecture | Target-specific toolchains, dependencies and tests |
| Cross-compilation | Native compiler producing another architecture | Languages and dependencies that support the target directly |

For this pure-Go fixture, both native ARM and cross-compilation avoided the long
emulated build. Cross-compilation still needs target-native testing. If your
application uses CGO, account for the target C compiler and linked libraries
before adopting the Dockerfile below.

## Cross-compile on the builder's architecture

Pin the build stage to `BUILDPLATFORM`, then pass `TARGETOS` and `TARGETARCH` to Go:

```dockerfile
FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
RUN go version && uname -m
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags='-s -w' -o /out/ci-lab .
FROM scratch
COPY --from=build /out/ci-lab /ci-lab
USER 65532:65532
ENTRYPOINT ["/ci-lab"]
```

The compiler runs on the builder's architecture while producing the requested
target executable. The final `scratch` stage contains that executable and declares
a non-root user.

The native and emulated variants omit `--platform=$BUILDPLATFORM` and the explicit
`GOOS`/`GOARCH` assignments. Their compiler follows the target architecture: ARM
code runs natively on the ARM runner and through QEMU on the x86 runner. The
source, dependency, optimization flags and output format remain the same.

Both Dockerfiles record `go version && uname -m` so the logs show where the compiler
executed. The `--platform linux/arm64` option alone does not establish that.

## Reproduce the comparison

Extract `fixture/` and `multiarch/` from the download into a disposable repository.
Copy `multiarch/evidence.yml` to `.github/workflows/multiarch-evidence.yml` and
dispatch it manually. No repository secrets or registry push are required.

The workflow uses GitHub-hosted `ubuntu-24.04` and `ubuntu-24.04-arm` runners. Our
run used a private repository; check the [runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
and your account's billing before reproducing it. The README records the tested
versions, timeouts and concurrency limits.

For a single cross-compilation sample, create a Docker-container builder, then
run the build from the extracted bundle's root:

```sh
docker buildx create --name experiment-builder --driver docker-container
docker buildx inspect experiment-builder --bootstrap
docker buildx build \
  --builder experiment-builder \
  --platform linux/arm64 \
  --no-cache --pull \
  --provenance=false --sbom=false \
  --progress=plain \
  -f multiarch/Dockerfile.cross \
  --output type=oci,dest=image.tar \
  fixture
docker buildx rm experiment-builder
```

The [OCI exporter](https://docs.docker.com/build/exporters/oci-docker/) requires a
compatible driver; the ordinary Docker driver does not support it. Attestations
are disabled here to simplify the measured archive structure. Keep your production
provenance and signing requirements separate from that experiment setting.

After the full workflow finishes, download and analyze its artifacts:

```sh
gh run download RUN_ID --repo OWNER/REPO --dir results
python3 multiarch/analyze.py results
```

The analyzer requires five successful samples per configuration and produces the
timing summary. It also combines the first native amd64 and arm64 archives into
a local multi-platform OCI layout. Registry upload and platform selection on pull
were not tested. Retain failed logs rather than removing unsuccessful samples
from the comparison.

## Validate the output

All five amd64 executables passed native amd64 checks, and all 15 ARM executables
passed native ARM checks. The ARM executables had identical SHA-256 hashes across
the three build strategies. Their image archives can differ because image
metadata also contributes to the archive contents.

The verifier checks OCI descriptor and layer hashes, image user and entrypoint,
ELF architecture, and expected executable output. It runs the extracted binary,
not the exported image as a container. Before releasing an application, pull and
run its final image on each target platform and run the application's own tests.

For a pure-Go build, start by comparing cross-compilation with native ARM. For
target-specific dependencies, test native ARM early. Make the final choice using
your whole workflow's duration and cost, including startup, caches, tests and
publication.
