← Back to DevBytes

Docker ARM vs AMD64 Images: Best Practices and Common Pitfalls

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:

Why Multi-Architecture Images Matter

The importance of supporting multiple architectures has grown dramatically due to several industry shifts:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles