Multi-Architecture Container Images#
You can no longer assume containers run only on x86. AWS Graviton instances are ARM64. Developer laptops with Apple Silicon are ARM64. Ampere cloud instances are ARM64. A container image tagged myapp:latest needs to work on both architectures, or you end up maintaining separate tags and hoping nobody pulls the wrong one.
Manifest Lists#
A manifest list (also called an OCI image index) lets a single tag point to multiple architecture-specific images. When a client pulls myapp:latest, the registry returns the image matching the client’s architecture.
# Inspect a manifest list to see what architectures are available
docker manifest inspect alpine:latestThe output shows entries for linux/amd64, linux/arm64, linux/arm/v7, and others. When you pull alpine:latest on an ARM64 machine, Docker automatically selects the ARM64 variant. Your images should work the same way.
Setting Up Buildx#
Docker buildx is a CLI plugin that extends docker build with multi-platform support. Create a builder instance:
docker buildx create --name multiarch --use
docker buildx inspect --bootstrapThe --bootstrap flag starts the builder immediately, so you can verify it supports the platforms you need. The default builder usually supports linux/amd64 and linux/arm64 out of the box.
Building Multi-Arch Images#
The basic command builds for multiple platforms and pushes to a registry in one step:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t ghcr.io/myorg/myapp:latest \
.The --push flag is required for multi-arch builds. Buildx cannot load a multi-platform image into the local Docker daemon because the local image store only holds one architecture at a time. If you omit --push, the build completes but the image goes nowhere.
To build for a single platform and load it locally (useful for testing):
docker buildx build --platform linux/arm64 --load -t myapp:latest .Build Strategies#
There are three approaches to multi-arch builds. They differ dramatically in speed and complexity.
Strategy 1: Cross-Compilation in Dockerfile (Fastest)#
The build runs on your native architecture. The compiler inside the container targets the other architecture. No emulation is involved.
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN GOOS=linux GOARCH=$TARGETARCH CGO_ENABLED=0 go build -o /app ./cmd/server
FROM alpine:3.20
COPY --from=builder /app /app
ENTRYPOINT ["/app"]The key is --platform=$BUILDPLATFORM on the build stage. This tells buildx to run the build stage on the host architecture (fast, native). The $TARGETARCH variable is set automatically by buildx to each target platform (amd64, arm64). The final stage uses the target platform by default, so the runtime image matches the deployment architecture.
This is the gold standard for Go, Rust, and any language with cross-compilation support.
Strategy 2: QEMU Emulation (Simplest but Slowest)#
Buildx transparently uses QEMU to emulate the target architecture. Your Dockerfile does not need any changes:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
CMD ["node", "server.js"]docker buildx build --platform linux/amd64,linux/arm64 --push -t myapp:latest .The ARM64 build runs under QEMU emulation on your x86 machine (or vice versa). This works but is 5-10x slower than native builds. For interpreted languages (Node.js, Python) this is acceptable because npm install and pip install are I/O-bound. For compiled languages, especially Go, QEMU emulation is unreliable and will crash on lfstack.push in Go’s runtime.
Strategy 3: Native Builders (Fastest, Most Complex)#
Use ARM64 hardware for the ARM64 build and x86 hardware for the x86 build. Buildx can coordinate remote builders:
# Add a remote ARM64 builder node
docker buildx create --name multiarch --node arm-builder \
--platform linux/arm64 \
ssh://user@arm64-host
# Add a local x86 builder node
docker buildx create --name multiarch --node x86-builder \
--platform linux/amd64 \
--append
docker buildx use multiarchEach platform builds on its native hardware. This is the fastest option but requires maintaining builder infrastructure on both architectures.
Base Image Compatibility#
Before building multi-arch, verify your base images support all target platforms. Common base images and their ARM64 support:
| Base Image | ARM64 Support |
|---|---|
alpine |
Yes |
debian |
Yes |
ubuntu |
Yes |
golang |
Yes |
node |
Yes |
python |
Yes |
mattermost/mattermost-team-edition |
No |
| Many vendor-specific images | Check first |
If your base image lacks ARM64 support, the build will fail at pull time for that platform. Check before committing to a multi-arch strategy:
docker manifest inspect node:20-alpine | jq '.manifests[].platform'Inspecting Built Images#
After pushing, verify the manifest list contains both architectures:
docker manifest inspect ghcr.io/myorg/myapp:latestIf only one architecture appears, the build for the other platform silently failed or was not included.
CI Integration#
In GitHub Actions, the standard pattern combines buildx setup with multi-platform build-and-push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxIf your Dockerfile uses cross-compilation (Strategy 1), the QEMU action is only needed for the final stage’s base image pull. The actual compilation runs natively on x86, keeping build times fast.
Common Mistakes#
- Building on emulation when cross-compilation is available. A Go binary cross-compiled with
GOARCH=arm64builds in seconds. The same binary built under QEMU emulation takes minutes and may crash. - Forgetting
--push. Buildx cannot load multi-arch images into the local daemon. Without--push, the build produces nothing usable. - Base image does not support the target architecture. Always check with
docker manifest inspectbefore adding a platform to your build. - Sharing build cache across architectures. Cached layers from x86 are not valid for ARM64. Use platform-aware cache keys.
- Not testing pulls on both architectures. The manifest list might exist, but the ARM64 image might be broken. Pull and run on actual ARM64 hardware or emulation to verify.