Understanding Docker Multi-Architecture Images
When you pull a Docker image from a registry, you might not realize that behind the single image tag lies a sophisticated mechanism that delivers the correct binary for your CPU architecture. Docker images can target multiple processor architectures — most commonly amd64 (x86_64, Intel/AMD) and arm64 (aarch64, ARM 64-bit, including Apple Silicon M-series and AWS Graviton). Understanding how multi-architecture images work is essential for anyone building containerized applications that need to run across diverse hardware.
This tutorial covers the core concepts, practical workflows, best practices, and the most common pitfalls developers encounter when working with ARM and AMD64 Docker images.
What Are ARM and AMD64 Docker Images?
At a fundamental level, Docker images are composed of layers containing binaries, libraries, and configuration files. These binaries are compiled for a specific CPU instruction set architecture (ISA). An amd64 image contains binaries compiled for the x86-64 architecture, while an arm64 image contains binaries compiled for the ARM 64-bit architecture. The two are not interchangeable — attempting to run an ARM binary on an x86 processor (or vice versa) will result in an exec format error or simply fail to start.
Docker solves this cross-architecture problem with two key features:
- Docker manifests and manifest lists: A manifest list (also called a fat manifest or multi-arch image) is a JSON document that points to multiple platform-specific image manifests. When you push or pull an image, the Docker client negotiates with the registry to fetch the variant that matches your host architecture.
- Buildx and cross-compilation: Docker Buildx is an extended build tool that supports creating images for multiple platforms simultaneously, using either emulation (QEMU) or native cross-compilation.
Why Multi-Architecture Images Matter
The importance of supporting multiple architectures has grown dramatically due to several industry shifts:
- Apple Silicon Macs (M1/M2/M3/M4): Millions of developers now run containers locally on ARM64 machines. Pulling an amd64-only image forces Docker Desktop to use emulation via Rosetta or QEMU, which incurs a noticeable performance penalty.
- AWS Graviton / Azure Ampere Altra: Cloud providers offer ARM-based instances at significantly lower cost (often 20-40% cheaper) with excellent performance-per-watt. Running native arm64 containers on these instances eliminates emulation overhead entirely.
- Edge computing and IoT: Raspberry Pi and other ARM-based devices are ubiquitous in edge deployments. A single image tag that works everywhere simplifies CI/CD pipelines dramatically.
- Cost optimization: Being able to schedule workloads on either architecture based on availability and pricing requires container images that support both natively.
How Multi-Architecture Images Work Under the Hood
Let's examine a multi-architecture manifest. When you inspect an image like nginx:latest, Docker's experimental CLI features reveal the underlying structure:
# Enable experimental CLI features
export DOCKER_CLI_EXPERIMENTAL=enabled
# Inspect the manifest list
docker manifest inspect nginx:latest
The output shows a manifest list containing platform-specific entries. Each entry specifies the OS, architecture, and variant, along with the digest of the actual image manifest for that platform:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 1570,
"digest": "sha256:abc123...",
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 1570,
"digest": "sha256:def456...",
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}
When you run docker pull nginx:latest on an ARM64 machine, the Docker client reads this manifest list, identifies the entry matching linux/arm64, and pulls only the layers referenced by that specific image manifest. This is seamless — you use the exact same command regardless of architecture.
Building Multi-Architecture Images with Docker Buildx
The modern way to build multi-architecture images is with docker buildx. Buildx is included with Docker Desktop and most Docker Engine installations. Let's walk through a complete setup.
Step 1: Create a Buildx Builder Instance
First, create a builder that supports multiple architectures. The docker-container driver is recommended because it allows you to use QEMU emulation for cross-platform builds:
# Create a new builder instance
docker buildx create \
--name multiarch-builder \
--driver docker-container \
--use
# Inspect the builder to verify platform support
docker buildx inspect --bootstrap
The --bootstrap flag ensures the builder starts and QEMU binaries are registered. You should see output confirming that linux/amd64 and linux/arm64 (among others) are supported:
Name: multiarch-builder
Driver: docker-container
Nodes:
Name: multiarch-builder0
Endpoint: unix:///var/run/docker.sock
Platforms: linux/amd64, linux/arm64, linux/arm/v7, linux/arm/v6
Step 2: Write a Dockerfile That Works Across Architectures
Here's a practical Dockerfile for a Node.js application that builds correctly on both amd64 and arm64:
# Use the Node.js official image which is already multi-arch
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# For applications that need native addons, rebuild them
RUN npm rebuild
# Final stage - use a minimal base
FROM node:20-alpine
# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy built artifacts from builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/src ./src
# Switch to non-root user
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]
Key points about architecture-agnostic Dockerfiles:
- Base images like
node:20-alpineare already multi-arch — they exist for both amd64 and arm64. - Avoid hardcoding architecture-specific binary URLs. Instead, use environment variable substitution or detect the architecture at build time.
- For native addons (like
node-gypcompiled modules), thenpm rebuildcommand recompiles them for the target architecture automatically.
Step 3: Build and Push a Multi-Architecture Image
Buildx allows you to build for multiple platforms in a single command and push directly to a registry:
# Build for both amd64 and arm64, push to registry
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag your-registry/your-app:latest \
--push \
.
# If you only want to build locally without pushing:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag your-app:latest \
--load \
.
Important distinction: --push pushes the full manifest list to a registry. --load loads a single-platform image into your local Docker engine (you can only load one platform at a time with --load). If you need to test multiple platforms locally, push to a registry first or use separate --load commands for each platform.
Step 4: Handling Architecture-Specific Dependencies
Sometimes you need different packages or binaries per architecture. Use Buildx's built-in ARG variables TARGETARCH and TARGETOS:
FROM alpine:latest
# Buildx automatically sets these ARGs
ARG TARGETARCH
ARG TARGETOS
RUN echo "Building for ${TARGETOS}/${TARGETARCH}"
# Download architecture-specific binary
RUN if [ "$TARGETARCH" = "amd64" ]; then \
wget https://example.com/binary-amd64; \
elif [ "$TARGETARCH" = "arm64" ]; then \
wget https://example.com/binary-arm64; \
else \
echo "Unsupported architecture: $TARGETARCH" && exit 1; \
fi
# Or use a more elegant approach with case statements
RUN case "$TARGETARCH" in \
amd64) ARCH_SUFFIX="amd64" ;; \
arm64) ARCH_SUFFIX="arm64" ;; \
*) echo "Unsupported arch" && exit 1 ;; \
esac && \
wget "https://example.com/binary-${ARCH_SUFFIX}"
Buildx automatically populates TARGETARCH, TARGETOS, TARGETVARIANT, and TARGETPLATFORM based on the platform being built. You don't need to set them manually.
Using Docker Manifest for Existing Single-Arch Images
If you have separate images already built for different architectures (perhaps from a legacy CI pipeline), you can stitch them together into a manifest list using docker manifest commands:
# Enable experimental features
export DOCKER_CLI_EXPERIMENTAL=enabled
# Create a manifest list
docker manifest create myapp:latest \
myapp:amd64-latest \
myapp:arm64-latest \
--amend
# Annotate each entry with the correct platform
docker manifest annotate myapp:latest \
myapp:arm64-latest \
--os linux --arch arm64
docker manifest annotate myapp:latest \
myapp:amd64-latest \
--os linux --arch amd64
# Push the manifest list
docker manifest push myapp:latest
This approach works well for migrating existing single-arch images to a unified multi-arch tag without rebuilding.
Best Practices for Multi-Architecture Docker Images
Based on real-world experience deploying containerized applications across ARM and x86 infrastructure, here are the essential best practices:
1. Always Use Multi-Arch Base Images
Start your Dockerfiles with base images that already support multiple architectures. Most official images on Docker Hub (alpine, ubuntu, node, python, golang, nginx, redis) are multi-arch. Verify by checking the image's tags on Docker Hub — look for the "Supported architectures" section or use docker manifest inspect.
# Good: multi-arch base image
FROM python:3.12-slim
# Avoid: obscure single-arch images unless you verify they support your targets
FROM some-obscure-image:latest # Check supported architectures first
2. Avoid Hardcoding Architecture Strings in Scripts
Never write scripts that assume amd64 or x86_64. Use TARGETARCH during build and uname -m at runtime:
# At build time - use Buildx ARGs
ARG TARGETARCH
RUN echo "Building for ${TARGETARCH}"
# At runtime - detect dynamically
RUN ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) echo "Running on amd64" ;; \
aarch64) echo "Running on arm64" ;; \
esac
3. Test on Both Architectures in CI
Your CI pipeline should validate the image on both amd64 and arm64. Here's a GitHub Actions example snippet that builds and smoke-tests both platforms:
jobs:
build-and-test:
strategy:
matrix:
platform:
- linux/amd64
- linux/arm64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build for ${{ matrix.platform }}
uses: docker/build-push-action@v5
with:
platforms: ${{ matrix.platform }}
tags: app-test:${{ matrix.platform }}
load: true
- name: Smoke test
run: |
docker run --rm app-test:${{ matrix.platform }} \
node -e "console.log('Test passed on this architecture')"
4. Use Single, Unified Image Tags
Don't tag images with architecture suffixes like myapp:1.0-amd64 unless you're maintaining legacy tags. A single tag like myapp:1.0 that resolves to the correct architecture via manifest lists is the standard practice. This simplifies deployment manifests, Helm charts, and docker-compose files — the same tag works everywhere.
5. Be Mindful of Native Compiled Dependencies
If your application uses native compiled code (Node.js C++ addons, Python C extensions, Go cgo, Rust FFI), ensure the build process compiles for the target architecture. Use multi-stage builds to keep the compilation toolchain in a builder stage and copy only the compiled artifacts to the final image.
FROM golang:1.22-alpine AS builder
ARG TARGETARCH
WORKDIR /build
COPY . .
RUN GOARCH=$TARGETARCH go build -o app .
FROM alpine:latest
COPY --from=builder /build/app /usr/local/bin/app
CMD ["/usr/local/bin/app"]
6. Keep Image Sizes Comparable Across Architectures
Monitor that your arm64 image isn't significantly larger than the amd64 equivalent. Large discrepancies can indicate leftover build artifacts or architecture-specific bloat. Use docker buildx build with the --sbom and --attest flags for supply chain attestations without bloating the image.
7. Use QEMU Emulation for Builds, Not for Production
QEMU-based emulation during docker buildx builds is acceptable — it's slower but works reliably. However, never rely on emulation for production workloads. Running an amd64 container under QEMU on an ARM host can be 5-10x slower than a native arm64 container. Always deploy native-architecture images to production.
Common Pitfalls and How to Avoid Them
Even experienced teams encounter these issues regularly. Here's how to recognize and fix them:
Pitfall 1: "Exec Format Error" When Running Containers
Symptom: You pull an image and try to run it, but the container immediately exits with an error like:
exec /usr/local/bin/app: exec format error
Cause: The image contains binaries compiled for a different architecture than your host. This commonly happens when you build an amd64 image on an Intel machine, push it, and then try to run it on an ARM64 machine (or vice versa).
Fix: Ensure you're pulling the correct architecture. Use docker manifest inspect to check if the tag has a manifest for your platform. If the image is single-arch, you may need to explicitly pull the correct variant:
# Explicitly pull arm64 variant (if available)
docker pull --platform linux/arm64 myimage:latest
# Check what architecture an image targets
docker image inspect myimage:latest | grep Architecture
Pitfall 2: Docker Buildx --load Only Loads One Platform
Symptom: You run docker buildx build --platform linux/amd64,linux/arm64 --load expecting both images locally, but only one appears.
Cause: The --load flag can only import a single-platform image into the local Docker engine due to limitations in the Docker image store format. Multi-platform images require a registry-backed manifest list.
Fix: Either push to a registry (--push) or build each platform separately and load them with distinct tags:
# Build and load each platform separately
docker buildx build --platform linux/amd64 -t myapp:amd64 --load .
docker buildx build --platform linux/arm64 -t myapp:arm64 --load .
# Then create a manifest list pointing to the registry copies
# or test each locally with its architecture tag
Pitfall 3: Native Dependencies Compiled for Wrong Architecture
Symptom: Your Node.js or Python application crashes at runtime with module loading errors, often containing cryptic messages about invalid ELF headers or missing symbols.
Error: /app/node_modules/bcrypt/lib/binding/bcrypt.node: invalid ELF header
Cause: Native addons like bcrypt, sharp, or Python numpy were compiled for the build machine's architecture (e.g., amd64) but the container is running on arm64. These binaries are architecture-specific and don't survive cross-platform copying.
Fix: Always run npm rebuild or pip install --no-cache-dir during the image build process on the target architecture. In multi-stage builds, compile in the builder stage which runs under QEMU emulation for the target platform:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
# This will compile native addons for the TARGETARCH
RUN npm rebuild
COPY . .
RUN npm run build
FROM node:20-alpine
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
Pitfall 4: Assuming Shell Scripts and Tools Are Identical
Symptom: A shell script that works on amd64 fails on arm64 with errors about missing commands or different output formats.
Cause: While Alpine Linux provides the same core utilities across architectures, some packages may have version differences or architecture-specific quirks. Additionally, if you use uname -m, the output differs: x86_64 on amd64 vs aarch64 on arm64.
Fix: Parameterize scripts with TARGETARCH during build and use portable detection at runtime:
# Portable architecture detection at runtime
detect_arch() {
case "$(uname -m)" in
x86_64) echo "amd64" ;;
aarch64) echo "arm64" ;;
armv7l) echo "arm/v7" ;;
*) echo "unknown" ;;
esac
}
ARCH=$(detect_arch)
echo "Running on architecture: $ARCH"
Pitfall 5: Missing QEMU Binaries for Cross-Platform Builds
Symptom: Running docker buildx build --platform linux/arm64 on an amd64 host fails with errors about missing emulation or "failed to solve."
Cause: The docker-container driver for Buildx requires QEMU user-mode emulation binaries to be registered via binfmt_misc. These are included in Docker Desktop by default but may be missing on a plain Docker Engine Linux installation.
Fix: Install QEMU and register the binary format handlers:
# Install QEMU user mode emulation
sudo apt-get update && sudo apt-get install -y qemu-user-static
# Or use the multi-platform binfmt image to register handlers
docker run --privileged --rm tonistiigi/binfmt --install all
# Verify registration
ls /proc/sys/fs/binfmt_misc/
cat /proc/sys/fs/binfmt_misc/qemu-aarch64
Pitfall 6: CI/CD Pipeline Builds on Wrong Architecture
Symptom: Your CI pipeline builds and pushes an image that only works on amd64, even though you intended it to be multi-arch.
Cause: Many CI runners (GitHub Actions, GitLab CI default runners) are amd64-only. If you don't explicitly configure Buildx with QEMU or use a matrix strategy, the build will only produce an amd64 image.
Fix: Use docker/setup-buildx-action with QEMU in your CI pipeline:
# GitHub Actions example
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push multi-arch image
uses: docker/build-push-action@v5
with:
platforms: linux/amd64,linux/arm64
push: true
tags: registry/app:latest
For GitLab CI, add the QEMU setup step before your build stage. For Jenkins or other CI systems, ensure the build node has qemu-user-static installed and binfmt_misc handlers registered.
Pitfall 7: Image Cache Invalidation Across Architectures
Symptom: Builds take excessively long because layer caching doesn't work across different architectures.
Cause: Docker's layer cache is architecture-specific. A layer built for amd64 won't be reused when building for arm64, even if the Dockerfile instructions are identical. This effectively doubles build time in CI.
Fix: Use registry-based caching with Buildx's --cache-from and --cache-to flags, which can cache layers per architecture:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=registry/app:cache \
--cache-to type=registry,ref=registry/app:cache,mode=max \
--push \
-t registry/app:latest \
.
Alternatively, structure your Dockerfile so that architecture-agnostic steps (like copying source code) come after architecture-specific steps (like installing native packages). This maximizes the shared cache potential.
Practical Example: Full Multi-Arch CI/CD Pipeline
Here's a complete GitHub Actions workflow that builds, tests, and pushes a multi-architecture Docker image with proper caching:
name: Build Multi-Arch Docker Image
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix={{branch}}-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=buildkit-${{ github.ref }}
cache-to: type=gha,mode=max,scope=buildkit-${{ github.ref }}
- name: Verify multi-arch manifest
run: |
docker manifest inspect \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
| grep -E '"architecture"' | wc -l
echo "Expected 2 architectures (amd64 + arm64)"
This workflow uses GitHub Actions cache (type=gha) to speed up subsequent builds, pushes a proper manifest list with both architectures, and verifies the result.
Conclusion
Supporting both ARM64 and AMD64 architectures in your Docker images is no longer optional — it's a fundamental requirement for modern containerized applications. With Docker Buildx, QEMU emulation for builds, and manifest lists for distribution, the tooling has matured to make multi-architecture support straightforward. The key takeaways are: always start with multi-arch base images, use TARGETARCH for architecture-specific logic, rebuild native dependencies during the container build, test on both platforms in CI, and push unified manifest lists rather than architecture-specific tags. By following the best practices outlined here and being aware of the common pitfalls — from exec format errors to CI misconfigurations — you can deliver container images that run natively and efficiently across the full spectrum of modern cloud and edge hardware.