How to Build Multi-Arch Docker Images with Buildx (amd64 + arm64, 2026)

Oct 31, 2026 · 12 min · Ajay Kumar

This guide is for anyone who needs one image tag that runs on both x86 servers and ARM (Graviton, Axion, Cobalt, Apple silicon laptops). By the end you will have a platform-aware Dockerfile, a local docker buildx build --platform linux/amd64,linux/arm64 that pushes a proper manifest list, and a GitHub Actions workflow that builds each architecture natively on its own runner and merges the results. Versions current as I write: buildx v0.38.1 and BuildKit v0.34.1, both released 20 October 2026, plus docker/build-push-action@v7, setup-buildx-action@v4, setup-qemu-action@v4 and login-action@v4. You need Docker Engine 29+ or Docker Desktop, and a registry you can push to. If you are deciding whether ARM is worth it at all, read the companion post on ARM64 migration and Graviton5 pricing in 2026 first.

Step 1: Check your builder and what it can target

A multi-arch image is an image index, also called a manifest list. It points to one manifest per platform, and the runtime pulls the entry that matches its CPU. Docker's multi-platform docs say this needs an image store that understands manifest lists. Docker Desktop and Engine 29.0+ use the containerd image store by default, so on a fresh install it just works. On an older or upgraded Engine, create a docker-container builder instead:

docker buildx version            # expect v0.38.x or similar
docker buildx ls                 # PLATFORMS column lists what each builder can target

# Only needed on Engine < 29 or an upgraded install still on the classic store
docker buildx create --name multiarch --driver docker-container --bootstrap --use
docker buildx inspect --bootstrap   # "Platforms:" should include linux/amd64 and linux/arm64

If linux/arm64 is missing on an x86 Linux host, emulation is not registered. See Step 4.

Step 2: Make the Dockerfile platform-aware

BuildKit sets automatic build arguments in every stage that declares them. BUILDPLATFORM is the machine doing the build. TARGETPLATFORM, TARGETOS and TARGETARCH describe the image you are producing. There are two rules that save you most of the pain:

  1. Pin compile stages to --platform=$BUILDPLATFORM and cross-compile, so the compiler runs at native speed instead of under emulation.
  2. Never hard-code amd64 or x86_64 in a download URL. Map from TARGETARCH.
# syntax=docker/dockerfile:1
# Go cross-compiles cleanly, so the build stage always runs natively.
FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.23 AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

# Fetching a prebuilt tool: derive the arch, never hard-code it.
FROM --platform=$BUILDPLATFORM alpine:3.23 AS tools
ARG TARGETARCH
ARG KUBECTL_VERSION=v1.37.2
RUN apk add --no-cache curl \
 && curl -fsSLo /kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl" \
 && chmod +x /kubectl

# Final stage has no --platform flag, so it uses the target platform's base image.
FROM alpine:3.23
COPY --from=build /out/server /usr/local/bin/server
COPY --from=tools /kubectl /usr/local/bin/kubectl
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/server"]

TARGETARCH produces amd64 and arm64, which happens to match the Kubernetes and Go naming. Some projects publish x86_64 and aarch64 instead, so map those in a shell case statement. Interpreted stacks (Python, Node, Ruby) cannot cross-compile native extensions this easily. For those, either build the dependency stage on a native arm64 runner (Step 6) or accept emulation. For C and C++, Docker recommends tonistiigi/xx as a cross-compilation helper. If image size is your next problem, the multi-stage build guide covers the slimming side.

Step 3: Build and push both platforms

# Build for both architectures and push the image index in one go
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag ghcr.io/acme/server:1.4.0 \
  --push .

# Smoke-test each variant explicitly (arm64 runs under emulation on an x86 host)
docker run --rm --platform linux/arm64 --entrypoint uname ghcr.io/acme/server:1.4.0 -m   # aarch64
docker run --rm --platform linux/amd64 --entrypoint uname ghcr.io/acme/server:1.4.0 -m   # x86_64

--load with two platforms only works with the containerd image store. With a docker-container builder, push to a registry, or load one platform at a time with --platform linux/arm64 --load for local testing.

Step 4: Choose QEMU emulation or native builders

There are three strategies. BuildKit tries them in whatever way your Dockerfile and builder allow:

  • QEMU emulation. This needs no Dockerfile changes, but every RUN for the foreign architecture is emulated. Docker's own docs warn it "can be much slower" for compilation and compression. Official BuildKit releases now bundle QEMU user-mode emulators, and Docker Desktop ships them too. On a plain Linux host where emulation fails, register it:
# Registers QEMU handlers with binfmt_misc (privileged, affects the whole host kernel)
docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3 --install arm64
cat /proc/sys/fs/binfmt_misc/qemu-aarch64 | grep flags   # expect F among the flags
  • Native nodes. One builder with an amd64 node and an arm64 node, so each platform builds on real hardware:
# Docker contexts pointing at one x86 and one arm64 machine
docker context create node-amd64 --docker host=ssh://build@x86-builder
docker context create node-arm64 --docker host=ssh://build@arm-builder

docker buildx create --use --name native node-amd64
docker buildx create --append --name native node-arm64
docker buildx build --builder native --platform linux/amd64,linux/arm64 -t ghcr.io/acme/server:1.4.0 --push .
  • Cross-compilation. This is Step 2's --platform=$BUILDPLATFORM pattern, and the fastest option when your language supports it.

My rule: cross-compile when the language allows, use native runners when it does not, and keep QEMU for small final stages like apk add or apt-get install of a few packages.

Native multi-arch build: one job per platform, one merge build (linux/amd64) runs-on: ubuntu-24.04 push-by-digest=true build (linux/arm64) runs-on: ubuntu-24.04-arm push-by-digest=true merge job buildx imagetools create -t tag digest-a digest-b registry: image index linux/amd64 manifest linux/arm64 manifest At pull time the runtime reads the index and fetches only the manifest matching its CPU. QEMU alternative: one job, platforms: linux/amd64,linux/arm64, slower for compile-heavy RUN steps.
The distributed matrix-and-merge pattern used in Step 6: each platform builds natively and is pushed by digest, then a merge job publishes one tag that points at both manifests.

Step 5: Inspect the manifest list you pushed

Do not trust a green build. Look at the index. Here is real output from docker buildx imagetools inspect --raw alpine:3.23, reduced to platform and digest. I ran it on 31 October 2026:

docker buildx imagetools inspect --raw alpine:3.23 \
  | jq -r '.manifests[] | "\(.platform.os) \(.platform.architecture) \(.platform.variant // "") \(.digest[:19])"'
linux amd64  sha256:3d67941fed5a
unknown unknown  sha256:74a5b9d36e2d
linux arm v6 sha256:806bbcf86639
unknown unknown  sha256:fcd673cb8cff
linux arm v7 sha256:b43a13ac240c
unknown unknown  sha256:296e2ab3cfe9
linux arm64 v8 sha256:8793abc47938
unknown unknown  sha256:6d423cc7d247
...

The unknown/unknown entries are not broken platforms. They are attestation manifests (provenance and SBOM), which the human-readable output labels attestation-manifest. Your own image should show linux/amd64 and linux/arm64. If you see a plain manifest with no manifests array, you pushed a single-platform image.

Step 6: Build natively on GitHub Actions arm64 runners

GitHub's ubuntu-24.04-arm hosted runners became generally available for public repositories in August 2025. The runner reference now lists them for private repositories too: 4 vCPU and 16 GB for public repositories, 2 vCPU and 8 GB for private, billed per minute in private repositories. This workflow is the matrix-and-merge pattern Docker documented for years: build each platform on its own runner, push by digest, then merge. The current multi-platform GitHub Actions docs now point you to the reusable workflow below instead, but I still like seeing every step. It pushes to GHCR:

# .github/workflows/image.yml
name: image
on:
  push:
    branches: [main]
    tags: ["v*"]

permissions:
  contents: read
  packages: write

env:
  REGISTRY_IMAGE: ghcr.io/acme/server   # must be lowercase

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-24.04
          - platform: linux/arm64
            runner: ubuntu-24.04-arm
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v7
      - name: Prepare
        run: |
          platform=${{ matrix.platform }}
          echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v4
      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@v7
        with:
          platforms: ${{ matrix.platform }}
          tags: ${{ env.REGISTRY_IMAGE }}
          outputs: type=image,push-by-digest=true,name-canonical=true,push=true
          cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
          cache-to: type=gha,scope=${{ env.PLATFORM_PAIR }},mode=max
      - name: Export digest
        run: |
          mkdir -p ${{ runner.temp }}/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "${{ runner.temp }}/digests/${digest#sha256:}"
      - uses: actions/upload-artifact@v7
        with:
          name: digests-${{ env.PLATFORM_PAIR }}
          path: ${{ runner.temp }}/digests/*
          if-no-files-found: error
          retention-days: 1

  merge:
    runs-on: ubuntu-24.04
    needs: build
    steps:
      - uses: actions/download-artifact@v8
        with:
          path: ${{ runner.temp }}/digests
          pattern: digests-*
          merge-multiple: true
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v4
      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REGISTRY_IMAGE }}
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=sha
      - name: Create manifest list and push
        working-directory: ${{ runner.temp }}/digests
        run: |
          docker buildx imagetools create \
            $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
            $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
      - name: Inspect
        run: docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}

If you would rather not maintain the matrix, Docker now documents a reusable workflow, docker/github-builder (v1.18.1 at the time of writing). It computes the per-platform matrix, sends Linux Arm platforms to ubuntu-24.04-arm by default, and creates the final manifest for you:

jobs:
  build:
    uses: docker/github-builder/.github/workflows/build.yml@v1
    permissions:
      contents: read
      id-token: write
    with:
      output: image
      push: true
      platforms: linux/amd64,linux/arm64
      meta-images: user/app
      meta-tags: |
        type=ref,event=branch
        type=semver,pattern={{version}}
    secrets:
      registry-auths: |
        - registry: docker.io
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

The simple alternative is one job on ubuntu-24.04 with docker/setup-qemu-action@v4 and platforms: linux/amd64,linux/arm64. It is fine for small images and slow for anything that compiles. Whichever you pick, pin third-party actions to commit SHAs in production; my post on GitHub Actions supply-chain security explains why tags are not enough.

Step 7: Cache each platform separately

The gha cache backend's scope defaults to buildkit. Per the Docker gha cache docs, each build overwrites the previous build's cache in the same scope. In a matrix, that means the arm64 job evicts the amd64 cache and vice versa. Setting scope to the platform pair, as in Step 6, fixes that. Other things to know: GitHub's cache has size limits and evicts old entries, cache is only shared with the current, base and default branches, and mode=max caches intermediate stages too (bigger, but far better hit rates for multi-stage builds). For bigger images or self-hosted runners, a registry cache avoids the limits:

docker buildx build --platform linux/arm64 \
  --cache-from type=registry,ref=ghcr.io/acme/server:buildcache-arm64 \
  --cache-to   type=registry,ref=ghcr.io/acme/server:buildcache-arm64,mode=max \
  -t ghcr.io/acme/server:1.4.0-arm64 --push .

More CI speed tricks are in How to Speed Up GitHub Actions CI.

Troubleshooting

Symptom Likely cause Fix
exec /usr/local/bin/server: exec format error at container start Image or binary built for the other architecture (often a hard-coded amd64 download, or a single-platform tag) Check imagetools inspect; derive arch from TARGETARCH; rebuild with both platforms
exec /bin/sh: exec format error during a RUN step No QEMU emulation registered for the target platform Add docker/setup-qemu-action@v4, run tonistiigi/binfmt --install arm64, or build on a native arm64 runner
Multi-platform build is not supported for the docker driver Default docker driver on the classic image store Enable the containerd image store or docker buildx create --driver docker-container --use
docker exporter does not currently support exporting manifest lists --load (type=docker) with several platforms on a non-containerd setup Use --push, or load one platform at a time
no match for platform in manifest on pull Tag has no variant for this node's architecture Publish the missing platform, or pin the workload with a kubernetes.io/arch node selector
arm64 job takes many times longer than amd64 Compile steps running under QEMU Cross-compile with --platform=$BUILDPLATFORM, or use ubuntu-24.04-arm runners
Cache hit rate collapses after adding a matrix Both platforms share the default buildkit gha scope Set scope=<platform> on cache-from and cache-to
unknown/unknown entries in the index Provenance and SBOM attestation manifests Expected; disable with provenance: false only if a legacy registry rejects them
invalid reference format: repository name must be lowercase ${{ github.repository }} contains capitals Hard-code a lowercase name or lowercase it in a step
Segfault or exit code 139 only in the emulated build Runtime or JIT that misbehaves under QEMU user-mode emulation Move that platform to a native runner; do not debug QEMU in CI

FAQ

How do I build a Docker image for both amd64 and arm64?

Run docker buildx build --platform linux/amd64,linux/arm64 -t registry/image:tag --push . with a builder that supports multi-platform output. That means Docker Desktop, Engine 29+ with the containerd image store, or a docker-container builder. The result is one tag pointing at an image index with both variants.

Is QEMU or a native arm64 runner better for multi-arch builds?

Native is faster and more faithful, because nothing is emulated. QEMU is simpler, needing one job and no extra runners, and is fine when the emulated steps are small. If your Dockerfile compiles code, cross-compile or use native ubuntu-24.04-arm runners.

What causes exec format error in Docker?

The kernel was asked to run a binary built for a different CPU architecture. Either the image has no variant for the host's architecture, or a file inside it (often a downloaded tool) was built for the wrong one. Inspect the index and check the binary with file.

What is the difference between a manifest list and an image index?

They are the same idea in two formats. Docker's manifest list and the OCI image index both point at one manifest per platform. Modern buildx pushes OCI indexes, and registries and runtimes treat them the same way.

When a multi-arch pipeline misbehaves in production, the first thing I check is imagetools inspect on the exact tag that was deployed, not the one in the pull request. After that, I check whether any DaemonSet or sidecar in the namespace lacks an arm64 variant. In CI I build natively on arm64 runners for anything with a compile step, and I run the container's smoke test on each architecture before the merge job is allowed to publish the tag. The official references are Docker's multi-platform builds, multi-platform GitHub Actions and gha cache backend pages, plus GitHub's hosted runner reference.

Related: How to Reduce Docker Image Size: Multi-Stage Builds Guide (2026), How to Fix ImagePullBackOff and ErrImagePull in Kubernetes (2026) and Docker in 2026: Engine 29, containerd 2.3 LTS, Podman 6 and the AI Push.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related