Docker Multi-Architecture Image: Single-Arch to Multi-Arch Migration¶
Context¶
A Docker image built with docker build produces a single-platform image tied to the architecture of the host running the build. When the same image needs to run on both linux/amd64 and linux/arm64 hosts - such as when deploying to heterogeneous infrastructure or publishing a public image - the single-platform image must be replaced with a multi-architecture manifest list.
This entry documents the complete process of converting an existing single-platform Docker image into a multi-architecture image using docker buildx, covering the conceptual model, builder setup, platform-aware build execution, and registry push.
What Was Done¶
| Task | Outcome |
|---|---|
Multi-platform builder created using docker-container driver | Builder ready to target linux/amd64 and linux/arm64 simultaneously |
| QEMU binary format handlers registered | Foreign-architecture emulation enabled for cross-platform builds |
| Multi-arch image built and pushed to registry | Manifest list stored in registry pointing to both platform variants |
| Manifest list verified | Both linux/amd64 and linux/arm64 entries confirmed present |
Conceptual Overview¶
What a Single-Architecture Image Is¶
A Docker image is a stack of read-only filesystem layers bundled with metadata. When built on an amd64 host with docker build, every binary inside the image - the base OS packages, installed tools, and application binaries - is compiled for the x86_64 instruction set. The image carries platform metadata (linux/amd64) that tells the Docker daemon which CPU it requires. Running this image on an arm64 host either fails outright or requires software emulation.
What a Multi-Architecture Image Is¶
A multi-architecture image is not a single image file. It is a manifest list (defined by the OCI Image Index Specification) stored in a container registry. The manifest list is a JSON document that maps each platform to a separate image digest.
Manifest List (image index)
├── linux/amd64 --> sha256:aaa... (separate image, amd64 binaries)
└── linux/arm64 --> sha256:bbb... (separate image, arm64 binaries)
When docker pull myimage:tag runs, the Docker daemon reads the local host architecture, queries the registry manifest list, and pulls only the correct platform variant. The user specifies no platform flag. The selection is automatic and transparent.
Why docker build Cannot Produce Multi-Arch Images¶
docker build builds for exactly one platform - the native platform of the Docker daemon. It does not produce manifest lists and has no --platform flag that accepts multiple values. Producing a manifest list requires:
- Building each platform variant separately, and
- Assembling those variants into a manifest list in the registry
docker buildx handles both steps in a single command.
What docker buildx Is¶
docker buildx is Docker's extended build system built on BuildKit. It extends docker build with:
- Multi-platform builds targeting several architectures in one command
- Pluggable builder backends (local daemon, container, remote)
- Advanced caching and parallelism
- Direct push to a registry as the build output
buildx ships with Docker Desktop and has been included in Docker Engine since version 23. No separate installation is required on standard Docker installations.
What a Builder Instance Is¶
buildx uses the concept of a builder - a named BuildKit daemon with its own driver and configuration. The default builder created by Docker Desktop uses the docker driver, which is constrained to the host's native platform. Building for multiple platforms requires a builder using the docker-container driver, which:
- Runs the BuildKit daemon inside a container
- Supports QEMU-based emulation for non-native architectures
- Accepts the
--platformflag with multiple comma-separated targets
What QEMU Does During a Cross-Platform Build¶
QEMU is an open-source machine emulator. In the context of Docker multi-arch builds, it provides user-space binary translation: when a build step tries to execute a binary compiled for a foreign architecture (e.g., running an arm64 binary during a build on an amd64 host), the Linux kernel's binfmt_misc interface intercepts the execution, hands it to the registered QEMU handler for that architecture, and QEMU translates the binary's system calls in real time.
This allows a single build host to produce images for architectures other than its own, at the cost of slower build times compared to building natively on each target platform.
The tonistiigi/binfmt image registers QEMU handlers for all supported architectures. On Docker Desktop (macOS and Windows), this registration is handled automatically at startup. On a Linux Docker host, it must be run explicitly once.
The TARGETARCH Build Argument¶
When docker buildx build --platform linux/amd64,linux/arm64 runs, BuildKit injects several automatic build arguments into the Dockerfile for each platform variant being built:
| Variable | Example value (amd64) | Example value (arm64) |
|---|---|---|
TARGETPLATFORM | linux/amd64 | linux/arm64 |
TARGETARCH | amd64 | arm64 |
TARGETOS | linux | linux |
BUILDARCH | architecture of the build host | architecture of the build host |
These are available inside the Dockerfile by declaring ARG TARGETARCH without a default value. They allow RUN steps to branch on the target architecture when downloading binaries or selecting platform-specific packages:
ARG TARGETARCH
RUN case "${TARGETARCH}" in \
amd64) BINARY_URL="https://example.com/tool-linux-amd64" ;; \
arm64) BINARY_URL="https://example.com/tool-linux-arm64" ;; \
esac && \
curl -fsSL "${BINARY_URL}" -o /usr/local/bin/tool && \
chmod +x /usr/local/bin/tool
Why
ARG TARGETARCHwith no value? BuildKit injects these variables automatically only when they are declared asARGwithout an assigned value. If a default value is set (e.g.,ARG TARGETARCH=amd64), the injected value is overridden and the build always behaves as if it is targetingamd64.
Why --push Is Required for Multi-Arch Output¶
docker buildx build --load stores the built image into the local Docker daemon's image store. The local image store holds single-platform images only. It cannot store a manifest list. This means --load only functions when --platform specifies exactly one target.
When building for two or more platforms simultaneously, the output must go directly to a registry that supports OCI manifest lists. Docker Hub and GitHub Container Registry (ghcr.io) both support this format. The --push flag replaces --load for multi-platform builds.
Prerequisites¶
- Docker Engine version 23 or later, or Docker Desktop (any recent version)
- Docker daemon running
- A container registry account (Docker Hub,
ghcr.io, or any OCI-compatible registry) - The image tagged with a registry-qualified name:
<registry>/<namespace>/<image>:<tag> - On a Linux host only: root or
sudoaccess to register QEMU handlers (one-time setup)
On Docker Desktop (macOS or Windows): QEMU is pre-installed and registered automatically. Steps 1a and 1b below are not required and can be skipped.
Steps¶
Step 1 (Linux hosts only): Register QEMU Binary Format Handlers¶
Why
--privileged? Registering binary format handlers requires writing to/proc/sys/fs/binfmt_misc, which is a kernel interface. Container access to kernel interfaces requires the--privilegedflag. The--rmflag removes the container immediately after the handlers are registered - it is a one-shot operation, not a long-running service.
Expected output:
This registration persists until the host reboots. On most systems it is run once during initial setup. Some teams add it to a host bootstrap script or a systemd unit.
Step 2: Verify buildx Is Available¶
Expected output:
If this command returns docker: 'buildx' is not a docker command, the Docker installation predates the bundled buildx plugin. In that case, install it from the docker/buildx releases page.
Step 3: Create a Multi-Platform Builder¶
Why
--driver docker-containerand not the default driver? The defaultdockerdriver runs BuildKit inside the Docker daemon itself and is constrained to the host's native platform. Thedocker-containerdriver launches a dedicated BuildKit container that has QEMU handlers available and can cross-compile for foreign platforms.Why
--use?--usesets this builder as the active builder for all subsequentbuildxcommands in the current shell session. Without it, the new builder is created but not activated.
Expected output:
Step 4: Bootstrap the Builder and Confirm Supported Platforms¶
Why bootstrap before building?
--bootstrapstarts the BuildKit daemon container and initialises it. Without this step, the first build command triggers the bootstrap implicitly, making the initial build appear to hang without feedback. Running bootstrap explicitly confirms the builder is healthy and shows the platform list before any build is attempted.
Expected output (relevant section):
Name: multi-builder
Driver: docker-container
Nodes:
Name: multi-builder0
Status: running
Platforms: linux/amd64, linux/amd64/v2, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x
Confirm that both linux/amd64 and linux/arm64 appear in the Platforms list before proceeding. If either is absent, the QEMU registration in Step 1 did not complete successfully.
Step 5: Build and Push the Multi-Architecture Image¶
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag <registry>/<namespace>/<image>:<tag> \
--push \
<path-to-build-context>
Replace <registry>/<namespace>/<image>:<tag> with the fully qualified image name, for example docker.io/myuser/myimage:latest or ghcr.io/myorg/myimage:v1.0.
Replace <path-to-build-context> with the directory containing the Dockerfile, for example . for the current directory.
What happens during this build? BuildKit runs two parallel build pipelines - one targeting
linux/amd64and one targetinglinux/arm64. For the non-native target, QEMU provides instruction-set emulation for anyRUNsteps that execute binaries. Once both platform builds complete, BuildKit assembles a manifest list and pushes it to the registry in a single atomic operation. The local Docker daemon does not store either image variant locally.
Expected terminal output (structure):
[+] Building 142.3s (24/24) FINISHED
=> [linux/amd64] FROM docker.io/library/ubuntu:22.04
=> [linux/arm64] FROM docker.io/library/ubuntu:22.04
=> [linux/amd64] RUN apt-get update ...
=> [linux/arm64] RUN apt-get update ...
...
=> pushing manifest for <registry>/<namespace>/<image>:<tag>
Note: The
arm64build steps run slower than theamd64steps when the build host isamd64, because those steps execute under QEMU emulation. This is expected. The performance difference is visible in the build log timing.
Verification¶
Verify the Manifest List in the Registry¶
Expected output:
Name: <registry>/<namespace>/<image>:<tag>
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:...
Manifests:
Name: <registry>/<namespace>/<image>:<tag>@sha256:aaa...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Name: <registry>/<namespace>/<image>:<tag>@sha256:bbb...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
Both linux/amd64 and linux/arm64 entries in the Manifests section confirm the push succeeded and the manifest list is correctly formed. A docker pull from any supported platform will now resolve to the correct variant automatically.
If only one platform entry appears, the --platform flag was likely applied to a --load build rather than a --push build, and the manifest list was never assembled.
Pull and Test a Specific Platform Variant¶
To pull and test the arm64 variant explicitly on an amd64 host:
docker pull --platform linux/arm64 <registry>/<namespace>/<image>:<tag>
docker run --rm --platform linux/arm64 <registry>/<namespace>/<image>:<tag> uname -m
Expected output:
aarch64 confirms the container is running the arm64 image variant. On a native arm64 host, the same docker run command without the --platform flag produces the same output.
List Active Builders¶
Expected output (relevant columns):
NAME/NODE DRIVER/ENDPOINT STATUS PLATFORMS
multi-builder * docker-container
multi-builder0 ... running linux/amd64, linux/arm64, ...
default docker
default ... running linux/amd64
The * next to multi-builder confirms it is active. The absence of linux/arm64 from the default builder confirms why a new builder was required.
Troubleshooting¶
exec format error during build¶
Cause: QEMU handlers are not registered for the target architecture. This error appears in a RUN step when the build tries to execute a binary compiled for the non-native architecture.
Fix: Run the QEMU registration step (Step 1) and retry. On a Linux host, confirm with:
Expected output includes qemu-aarch64 and qemu-x86_64.
--load fails when multiple platforms are specified¶
Symptom: docker buildx build --platform linux/amd64,linux/arm64 --load returns an error such as:
Cause: The local Docker image store does not support manifest lists. --load only accepts a single platform target.
Fix: Replace --load with --push. To test a single platform variant locally without pushing, build with one platform:
Builder shows inactive status¶
Cause: The BuildKit container for the builder exited or was removed.
Fix:
docker buildx rm multi-builder
docker buildx create --name multi-builder --driver docker-container --use
docker buildx inspect --bootstrap
Key Decisions¶
docker-container driver over docker driver¶
The default docker driver builds natively only. The docker-container driver was chosen because it is the only local driver that supports multi-platform builds via QEMU. The tradeoff is that the first build requires pulling the BuildKit image, but this is a one-time cost.
--push over local export¶
Multi-platform output is a manifest list. The local Docker daemon cannot store manifest lists. --push is not a workaround - it is the correct and only supported output method for builds targeting multiple platforms simultaneously.