Skip to content
shellhacks.dev
CI/CD

How to Build Multi Arch Images in CI Without Surprises

Build multi arch images in CI for amd64 and arm64: QEMU versus native runners, cross compiling in the Dockerfile, manifest lists and verification.

6 min read
Summarize in AI
Illustration for "How to Build Multi Arch Images in CI Without Surprises": a terminal session, test results, a pipeline of commands.

Developers on Apple silicon, servers on x86 and cheaper ARM cloud instances mean an image that runs only on one architecture eventually fails with exec format error. A multi-architecture image is one tag backed by several builds, and the registry hands each client the right one. This post compares three ways to produce it in CI. It extends building and pushing images in CI. Examples use Docker Buildx 0.19 and GitHub Actions.

Option 1: QEMU emulation

.github/workflows/multiarch.ymlYAML
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ghcr.io/example/app:${{ github.sha }}

QEMU registers user-mode emulators so the runner can execute arm64 binaries. It is a two-line change and works for almost everything. The price is speed: steps that compile code or install packages for the foreign architecture run several times slower, so a build that takes three minutes may take twenty. Fine for small images, painful for large builds.

Option 2: native runners and a merge

YAML
jobs:
  build:
    strategy:
      matrix:
        include:
          - { platform: linux/amd64, runner: ubuntu-24.04 }
          - { platform: linux/arm64, runner: ubuntu-24.04-arm }
    runs-on: ${{ matrix.runner }}
    # build and push by digest, upload the digest as an artifact

  merge:
    needs: build
    runs-on: ubuntu-24.04
    steps:
      - run: |
          docker buildx imagetools create -t ghcr.io/example/app:1.4.0 \
            ghcr.io/example/app@sha256:AMD64DIGEST \
            ghcr.io/example/app@sha256:ARM64DIGEST

Each architecture builds on matching hardware, so there is no emulation, and the final job stitches the per-platform images into one manifest list with imagetools create. It is more YAML and the fastest route when a hosted ARM runner is available. The digests pass between jobs as artifacts or outputs.

Option 3: cross compile in the Dockerfile

DockerfileDockerfile
FROM --platform=$BUILDPLATFORM golang:1.24 AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

The build stage runs natively on the host architecture (BUILDPLATFORM) and the compiler produces a binary for the target (TARGETARCH), so nothing is emulated. Only the final stage, which copies files, is for the target platform. This is the best approach for Go, Rust and other languages with easy cross compilation. It does not help when the build needs the target's native libraries.

Verify

Shell
docker buildx imagetools inspect ghcr.io/example/app:1.4.0
docker run --rm --platform linux/arm64 ghcr.io/example/app:1.4.0 uname -m
Text
Manifests:
  Name:      ghcr.io/example/app:1.4.0@sha256:...
  Platform:  linux/amd64
  Name:      ghcr.io/example/app:1.4.0@sha256:...
  Platform:  linux/arm64
aarch64

The inspect output lists one manifest per platform. Running the other architecture locally also works through QEMU on a developer machine. Check that every base image in the Dockerfile publishes all platforms, since one that does not makes the build fail only for that architecture.

Where this does not apply

If nobody runs the image on another architecture, skip it: every platform doubles build time, storage and the test matrix. Test the arm64 image on arm64 at least once, because emulation hides some bugs. See Buildx multi-platform builds for the local workflow.

One last check: pull the image by digest on both architectures in a throwaway environment and run its health command, not only uname -m. A multi-arch tag that starts on amd64 and crashes on arm64 is the failure that emulation can hide, and the cheapest place to see it is the pipeline, before a customer on the other architecture does. See choosing a base image for which publish both.

Choosing the approach by what you build

The three options differ in cost, speed and complexity, and the right one depends on the work in the Dockerfile. Emulation with QEMU is the simplest, a single extra setup step and a platform list, and it works for any Dockerfile. It is also slow, commonly five to twenty times slower than native for compile-heavy steps, because every instruction of the foreign architecture runs through an instruction translator. Package installs and file copies are tolerable, and a large compile or a language runtime build can turn a one-minute job into half an hour. Native builders avoid that: one runner per architecture builds its own image and pushes it by digest, and a final step combines the digests into one manifest list. That needs ARM runners, which hosted services now offer, or your own machines. Cross-compilation inside the Dockerfile is the fastest and needs a language that supports it, such as Go and Rust, where the compiler runs natively on the build platform and targets the other one.

Measure on your own project before choosing. A project that is mostly a binary built by Go is a good candidate for cross-compilation, and one that installs many packages from source needs native builders.

How the manifest list ties it together

A multi-architecture image is a manifest list, an index that maps each platform to the digest of a single-architecture image. When a client pulls the tag, the runtime picks the entry for its own platform. With one buildx command and several platforms, the tool builds each and pushes the list in one step. With native runners, each job pushes its image under a temporary tag or by digest, and a last job runs docker buildx imagetools create with the digests to publish the final tag. Keep the intermediate pushes untagged or under a clearly temporary name, since a tag that points at a single architecture will break users on the other. The base image must support every platform you list: building for ARM from a base that only has an x86 image fails with an error about the platform, and checking the base image's manifest first saves a failed build.

In cross-compilation builds, the Dockerfile uses FROM --platform=$BUILDPLATFORM for the build stage and reads TARGETOS and TARGETARCH to set the compiler's target, then copies the result into a final stage for the target platform.

Verifying the result and testing each platform

Inspect the published tag with docker buildx imagetools inspect, which lists every platform and digest in the list, and confirm that each expected platform is present. Run a smoke test on each architecture: start the container, call a health endpoint or print the version, and check that it exits cleanly. An image that builds for ARM can still crash on ARM because of a native dependency, a wrong binary copied in, or an assumption about page size or the instruction set, and only running it shows that. Native runners make the test trivial. Under emulation, run the test inside QEMU, which is slow but works for a short check. Keep an eye on the size too, since each platform adds its own layers to the registry. And decide whether you need the extra architecture at all: if nobody runs the image on ARM, a second platform doubles the build time and storage for no benefit.

Cost, build time and when to skip a platform

Every added platform multiplies work. Two architectures mean two builds, two sets of layers in the cache and registry, and two places for a test to fail. Decide who needs each one. If your users run on Apple silicon laptops, on ARM cloud instances or on small boards, an ARM image saves them from running emulation themselves, which is slower for them than for you. If nobody asks, publish one platform and add the second when someone does. Keep the build matrix in one place so adding a platform is a one-line change, and record the expected platforms in a test that inspects the manifest after each publish. A silent loss of one platform, because a runner was misconfigured, is the kind of regression nobody notices until a user reports that the image does not start.

See Docker's multi-platform build guide and the multi-platform in GitHub Actions.

Also inDocker
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.