Skip to content

Fix: Docker Multi-Platform Build Not Working — buildx Fails, Wrong Architecture, or QEMU Error

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix Docker multi-platform build issues — buildx setup, QEMU registration, --platform flag usage, architecture-specific dependencies, and pushing multi-arch manifests to a registry.

The Problem

docker buildx build --platform linux/arm64 fails immediately:

ERROR: failed to solve: failed to read dockerfile: no such file or directory

Or the build runs but the image is the wrong architecture:

docker build --platform linux/arm64 -t myimage .
docker run --rm myimage uname -m
# x86_64  — expected aarch64

Or QEMU-based cross-compilation fails mid-build:

#8 [linux/arm64 4/7] RUN npm install
exec /bin/sh: exec format error

Or pushing a multi-platform manifest to a registry fails:

ERROR: failed to push: unexpected status: 400 Bad Request

Why This Happens

Docker’s multi-platform builds have several prerequisites that aren’t set up by default:

  • docker build (classic builder) ignores --platform for cross-arch builds — only docker buildx with a builder that supports multiple platforms can actually build for a different architecture. The default docker build command may silently build for the host architecture.
  • QEMU must be registered as a binfmt handler — to run non-native binaries (e.g., ARM64 binaries on an x86_64 host), the kernel’s binfmt_misc must map ARM64 executables to QEMU. Without this, you get exec format error.
  • The builder instance must be multi-platform-capable — the default buildx builder (docker-container driver) may only support a single platform. You need a builder created with the docker-container driver and QEMU available.
  • --load and --push are mutually exclusive with multi-platform--load (load image into local Docker) only works for single-platform builds. Multi-platform builds must be pushed directly to a registry with --push, or exported with --output.

The deeper reason this confuses people is that Docker has two completely different build engines coexisting on the same CLI. The classic builder (sometimes called “legacy” or “Docker build v0”) is the original implementation that ships with the Docker daemon. It reads Dockerfile, walks each instruction sequentially, and produces a single-platform image. BuildKit is a re-architected build engine introduced in 2018 and exposed through docker buildx from 2019. It runs builds inside a containerized “builder instance,” supports parallel execution, mounts, secrets, cache exporters, and — relevant here — multi-platform output via a manifest list. When you type docker build, the daemon chooses whichever engine is configured. When you type docker buildx build, BuildKit is mandatory. Almost every “the --platform flag did nothing” complaint comes from running docker build against the classic engine on a host where BuildKit wasn’t made the default.

The picture changed materially with Docker Engine 23.0 (Feb 2023), which made BuildKit the default builder for both docker build and docker buildx build on Linux. Before 23.0, you had to opt in with DOCKER_BUILDKIT=1 or by setting "features": { "buildkit": true } in /etc/docker/daemon.json. Documentation, tutorials, and CI templates from 2019-2022 still assume the classic builder is the default and explicitly export DOCKER_BUILDKIT=1. On a modern Docker installation that environment variable is redundant — but harmless. The version cutover also moved the build cache format and changed how --load interacts with multi-platform builds, which is why old GitHub Actions workflows often need updates after the runner image refreshes.

The third layer is the driver that backs your buildx builder. Buildx supports four drivers: docker (uses the local daemon, single-platform only, no concurrent builds), docker-container (spins up a BuildKit container, supports multi-platform with QEMU, the default for buildx create), kubernetes (runs BuildKit in a Pod, useful for shared CI clusters), and remote (connects to a remote BuildKit daemon over TCP). If docker buildx ls shows your active builder as default with driver docker, you cannot build multi-platform images no matter how many --platform flags you pass. You must create a docker-container builder, and that builder must have QEMU available so it can execute non-native binaries during build steps.

Fix 1: Set Up buildx and QEMU Correctly

Install and configure the prerequisites for multi-platform builds:

# Step 1: Verify buildx is available
docker buildx version
# buildx v0.12.0 docker-desktop  ← OK
# If not found: install Docker Desktop or update Docker Engine

# Step 2: Register QEMU binfmt handlers (Linux hosts only)
# Docker Desktop on Mac/Windows does this automatically
docker run --privileged --rm tonistiigi/binfmt --install all
# Installs QEMU for arm, arm64, riscv64, ppc64le, s390x, mips, mips64

# Verify QEMU registration
ls /proc/sys/fs/binfmt_misc/
# Should show: qemu-aarch64, qemu-arm, etc.

# Step 3: Create a new builder with multi-platform support
docker buildx create --name multiarch-builder --driver docker-container --use
docker buildx inspect --bootstrap
# Should show: Platforms: linux/amd64, linux/arm64, linux/arm/v7, ...

Verify the builder can handle your target platform:

docker buildx ls
# NAME/NODE              DRIVER/ENDPOINT  STATUS   PLATFORMS
# multiarch-builder *    docker-container running  linux/amd64, linux/arm64, linux/arm/v7

# If your platform is missing, rebuild with QEMU registered first
docker buildx rm multiarch-builder
docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --name multiarch-builder --driver docker-container --use
docker buildx inspect --bootstrap

Fix 2: Build and Push Multi-Platform Images

The correct workflow for multi-platform images:

# Build for multiple platforms and push to registry in one step
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag myregistry/myimage:latest \
  --push \
  .

# Build for a single non-native platform (for testing)
docker buildx build \
  --platform linux/arm64 \
  --tag myimage:arm64-test \
  --load \  # Load into local Docker daemon (single platform only)
  .

# Build and export to local tar file
docker buildx build \
  --platform linux/arm64 \
  --output type=oci,dest=myimage-arm64.tar \
  .

# Verify the manifest has multiple architectures
docker buildx imagetools inspect myregistry/myimage:latest
# Outputs manifest list with amd64 and arm64 entries

Multi-platform build in GitHub Actions:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
        # Installs QEMU binfmt handlers automatically

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        # Creates a multi-platform capable builder

      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Fix 3: Handle Architecture-Specific Dependencies

Some dependencies install different binaries per architecture. Handle this in the Dockerfile:

# Use ARG TARGETARCH — automatically set by buildx to the target platform
FROM node:20-alpine

ARG TARGETARCH
ARG TARGETOS

# Install architecture-specific binary
RUN case "${TARGETARCH}" in \
    "amd64") ARCH="x64" ;; \
    "arm64") ARCH="arm64" ;; \
    "arm") ARCH="armv7" ;; \
    *) echo "Unsupported architecture: ${TARGETARCH}" && exit 1 ;; \
    esac && \
    wget -O /usr/local/bin/mybin "https://releases.example.com/mybin-linux-${ARCH}" && \
    chmod +x /usr/local/bin/mybin

COPY . .
RUN npm ci
CMD ["node", "server.js"]

Use --platform=$BUILDPLATFORM for build-time tools:

# syntax=docker/dockerfile:1

# Build stage runs on the host platform (fast, no emulation)
FROM --platform=$BUILDPLATFORM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci  # Runs natively on host — much faster than under QEMU
COPY . .
RUN npm run build

# Runtime stage runs on the target platform
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

Note: --platform=$BUILDPLATFORM tells Docker to run that stage on the host platform (e.g., amd64) even when building for arm64. This avoids QEMU emulation for slow build tools like compilers and package managers.

Cross-compile CGO Go binaries:

# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.22 AS builder

ARG TARGETOS TARGETARCH

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
# CGO_ENABLED=0 for static binary; set GOOS/GOARCH for cross-compilation
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
    go build -ldflags="-s -w" -o /app/server .

FROM --platform=$TARGETPLATFORM scratch
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Fix 4: Fix QEMU exec format error

exec format error during a build step means QEMU isn’t handling the binary format:

# Check QEMU is installed and binfmt is registered
cat /proc/sys/fs/binfmt_misc/qemu-aarch64
# enabled
# interpreter /usr/bin/qemu-aarch64-static
# flags: OCF
# ...

# If qemu-aarch64 doesn't exist, re-register:
docker run --privileged --rm tonistiigi/binfmt --install arm64

# Verify QEMU is working by running an ARM64 container
docker run --rm --platform linux/arm64 alpine uname -m
# aarch64  ← correct

Persistent QEMU registration across reboots (Linux):

# Install qemu-user-static package (registers permanently)
sudo apt-get install -y qemu-user-static
sudo systemctl restart systemd-binfmt

# Or use the tonistiigi/binfmt image with --persistent flag
docker run --privileged --rm tonistiigi/binfmt --install all --persistent

Fix 5: Cache Multi-Platform Builds

Multi-platform builds are slow without caching. Use registry-based caching:

# Push cache to registry (works across machines and CI runs)
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --cache-from type=registry,ref=myregistry/myimage:buildcache \
  --cache-to type=registry,ref=myregistry/myimage:buildcache,mode=max \
  --tag myregistry/myimage:latest \
  --push \
  .

# GitHub Actions cache (for private repos or avoiding registry costs)
docker buildx build \
  --cache-from type=gha \
  --cache-to type=gha,mode=max \
  ...

Build matrix strategy — native builds for each arch (faster than QEMU):

# GitHub Actions: build natively on arm64 and amd64 runners, then merge
jobs:
  build:
    strategy:
      matrix:
        include:
          - platform: linux/amd64
            runner: ubuntu-latest
          - platform: linux/arm64
            runner: ubuntu-24.04-arm  # GitHub's ARM64 runner
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@v6
        with:
          platforms: ${{ matrix.platform }}
          outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true

      - name: Export digest
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"

      - uses: actions/upload-artifact@v4
        with:
          name: digests-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }}
          path: /tmp/digests/*

  merge:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: digests-*
          merge-multiple: true
          path: /tmp/digests

      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Create manifest list and push
        run: |
          docker buildx imagetools create \
            --tag ghcr.io/${{ github.repository }}:latest \
            $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
        working-directory: /tmp/digests

Version History: BuildKit, buildx, and Multi-Platform Builds

The order in which features landed in Docker explains why so many tutorials contradict each other on multi-platform builds.

Docker 18.06 (mid-2018) — BuildKit experimental. Shipped behind DOCKER_BUILDKIT=1. The classic builder was still the default, no multi-platform support yet.

Docker 19.03 (July 2019) — docker buildx plugin. The buildx CLI plugin shipped as the official BuildKit frontend. Introduced multi-platform builds via manifest-list output, the docker-container driver, and QEMU binfmt_misc integration. Most “modern” multi-platform tutorials were written against this version.

Docker 20.10 (Dec 2020) — buildx as a first-class command. Apple Silicon (M1) launched and turned multi-platform builds from a niche concern into a daily problem — Dockerfiles written on a Mac would not run on Linux servers without explicit --platform linux/amd64 flags.

Docker 22.06 (mid-2022) — BuildKit 0.10. Added SLSA-style build attestations and SBOM generation. Cache mounts in Dockerfiles became stable (RUN --mount=type=cache,target=/root/.cache), critical for fast multi-platform builds because each architecture needs its own cache slice.

Docker 23.0 (Feb 2023) — BuildKit is the default. The watershed release. docker build now uses BuildKit by default on Linux, so DOCKER_BUILDKIT=1 is no longer required. The classic builder is still available via DOCKER_BUILDKIT=0 for compatibility but is end-of-life. Multi-platform builds with --platform linux/amd64,linux/arm64 work without any opt-in environment variable, assuming a multi-platform-capable builder is in use.

Docker 24.0 (June 2023) — Buildx 0.11, containerd image store opt-in. The new containerd image store (still opt-in via Docker Desktop settings) lets docker images and docker load handle manifest lists directly, removing the historical restriction that --load was single-platform only when the daemon used the classic image store.

Docker 25.0 (Jan 2024) — containerd image store stable. Multi-platform --load finally works without registry round-trips when the containerd image store is enabled. The default for new Docker Desktop installs flipped to containerd in 2024-2025.

Docker 26.0 / 27.0 (2024) — Buildx 0.13/0.14. Better cache management, faster imagetools operations. GitHub Actions’ ubuntu-24.04-arm runners (GA 2024) removed the QEMU emulation penalty for ARM64 builds.

QEMU and binfmt_misc. The kernel’s binfmt_misc module is what makes docker run --platform linux/arm64 alpine work on an x86_64 host: it maps the ARM64 ELF magic number to /usr/bin/qemu-aarch64-static. The tonistiigi/binfmt image registers these mappings for every architecture (arm, arm64, riscv64, ppc64le, s390x, mips, mips64). Without it, the kernel sees an ELF it can’t execute and returns exec format error. Docker Desktop on macOS and Windows registers these handlers automatically (because the VM is always Linux-on-virtualization), but bare-metal Linux hosts and self-hosted CI runners must register them manually. The --persistent flag tells tonistiigi/binfmt to write the registrations in a form that survives reboots.

Still Not Working?

--load fails with multi-platformdocker buildx build --load only supports a single platform. When building for linux/amd64,linux/arm64, you must use --push to a registry or --output type=oci,dest=archive.tar. To test locally, build for a single platform first: --platform linux/arm64 --load.

Python packages fail to compile for ARM64 under QEMU — packages that compile C extensions (like numpy, Pillow, cryptography) can take 10-100x longer under QEMU emulation compared to native. Use pre-built wheels when available (pip install --only-binary=:all: package) or use the --platform=$BUILDPLATFORM technique with cross-compilation.

Registry doesn’t support manifest lists — older registries (including some self-hosted Harbor instances) may not support OCI manifest lists. Update your registry to a version that supports the OCI Image Index specification, or push each architecture separately with different tags.

Alpine-based images failing for ARM — some Alpine packages have ARM-specific issues. If you see Bus error or Illegal instruction during package installation, try using a Debian-based image (-slim variants) instead. ARM support in Alpine improved significantly after Alpine 3.17.

exec /bin/sh: exec format error only on the second-stage image — your build stage uses FROM --platform=$BUILDPLATFORM (host arch) but a later stage doesn’t, and you copied a binary from the build stage into a target-arch runtime stage. The host-built binary then fails to execute under QEMU. Fix by cross-compiling in the build stage (GOOS/GOARCH for Go, cargo build --target=... for Rust) so the binary already matches the target architecture, not the build host.

buildx cache not invalidating after upgrading the base image — the cache-from/cache-to registry cache is keyed by both the architecture and the BuildKit version. A cache built on BuildKit 0.12 may not be reused by BuildKit 0.14 for some instructions. Use cache-to=...,mode=max (writes all layers, not just the final stage) and pin BuildKit versions in CI with docker/setup-buildx-action@v3 with: version: v0.14.0 to keep caches stable.

failed to compute cache key with no useful detail — almost always a missing file referenced by a COPY instruction, but the BuildKit error is opaque. Re-run the build with BUILDKIT_PROGRESS=plain (or --progress=plain) to see the full per-line output. If you are calling buildx from a shell with set -e, a single missing file in a multi-stage build can also surface as this message — check that the parent script isn’t swallowing the real error.

For related Docker issues, see Fix: Docker Build Arg Not Available, Fix: Docker Multi-Stage Build Failed, Fix: Docker exec format error, and Fix: Docker Layer Cache Invalidated.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles