How to Build Multi-Arch Docker Images with Buildx (amd64 + arm64, 2026)
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:
- Pin compile stages to
--platform=$BUILDPLATFORMand cross-compile, so the compiler runs at native speed instead of under emulation. - Never hard-code
amd64orx86_64in a download URL. Map fromTARGETARCH.
# 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
RUNfor 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=$BUILDPLATFORMpattern, 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.
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
How to Reduce Docker Image Size: Multi-Stage Builds Guide (2026)
Shrink Docker images with multi-stage builds, distroless or slim bases, .dockerignore, layer ordering and BuildKit cache mounts. Measured Go, Node, Python and Java examples: 343 MB to 3.4 MB, 423 MB to 56 MB, plus a troubleshooting table.
12 minOct 31, 2026ARM64 Migration in 2026: Graviton5 Costs 9% More and Still Undercuts x86
ARM in the cloud in 2026: AWS Graviton5 (M9g), Google Axion N4A and Azure Cobalt 200 compared on list price and vendor claims, what actually breaks when you migrate to arm64, and how to run mixed-arch Kubernetes clusters safely.
13 minOct 31, 2026How to Build an AWS VPC With Terraform: Public and Private Subnets (2026)
Build a 3-AZ AWS VPC in Terraform with terraform-aws-modules/vpc 6.7.3 and AWS provider 6.70: public and private subnets, NAT per AZ vs single vs regional, a free S3 gateway endpoint, flow logs, outputs, an SSM connectivity test and fixes for common errors.
16 min