Servers and laptops no longer share one CPU architecture: x86_64 (amd64) machines run next to ARM64 servers and Apple Silicon laptops. Docker Buildx, the BuildKit-based build command included with Docker Engine, can build one image for several architectures and publish them under a single tag, so every host pulls the variant that matches its CPU. In this tutorial you will set up Buildx with QEMU emulation on Ubuntu 24.04, build a small Go application for linux/amd64 and linux/arm64, push it to a registry and verify both variants.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, with at least 2 GB of RAM. Emulated builds are CPU intensive, so more cores help.
- Docker Engine installed from Docker's official repository, which includes the
docker-buildx-pluginpackage. Your user should be able to rundockercommands. - An account on a container registry. This guide uses Docker Hub; replace
your_dockerhub_userwith your user name. Any OCI registry (GitHub Container Registry, Harbor, a private registry) works the same way.
Confirm Buildx is installed:
docker buildx version
github.com/docker/buildx v0.xx.x ...
If the command is not found, install it with sudo apt install docker-buildx-plugin.
How multi-architecture images work
A multi-architecture image is a manifest list (also called an image index): one tag that points to several images, one per platform. When a host pulls your_dockerhub_user/hello:1.0, the registry returns the list and the Docker client downloads only the image for its own platform.
Buildx can produce the per-platform images in three ways:
| Method | How it works | When to use it |
|---|---|---|
| QEMU emulation | Runs foreign binaries through binfmt_misc | Simplest; fine for small builds, slow for heavy compilation |
| Cross-compilation | Compiler on the build host targets another CPU | Fast; works for Go, Rust, and other toolchains that support it |
| Native nodes | One builder with an amd64 and an arm64 node | Fastest for large builds; needs a second machine |
This tutorial combines the first two: QEMU for the final stage and cross-compilation for the Go binary.
Step 1 - Installing QEMU emulators
Register QEMU handlers in the kernel's binfmt_misc so the host can execute binaries built for other architectures. The tonistiigi/binfmt image, maintained by the BuildKit team, installs them:
docker run --privileged --rm tonistiigi/binfmt --install arm64,arm
The output lists the emulators that are now available:
installing: arm64 OK
installing: arm OK
{
"supported": [
"linux/amd64",
"linux/arm64",
"linux/386",
"linux/arm/v7",
"linux/arm/v6"
],
...
}
The registration is stored in the kernel and does not survive a reboot. Run the same command again after rebooting, or install the Ubuntu packages that register the handlers permanently at boot:
sudo apt install -y qemu-user-static binfmt-support
Step 2 - Creating a Buildx builder
The default builder uses the Docker daemon's built-in BuildKit, which with the default image store cannot export multi-platform images. Create a builder that runs BuildKit in its own container:
docker buildx create --name multiarch --driver docker-container --use --bootstrap
--use makes it the active builder and --bootstrap starts it right away. Inspect it:
docker buildx inspect
Name: multiarch
Driver: docker-container
...
Nodes:
Name: multiarch0
Endpoint: unix:///var/run/docker.sock
Status: running
Platforms: linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/arm64, linux/386, linux/arm/v7, linux/arm/v6
linux/arm64 in the platform list confirms that the builder can use the QEMU emulators. docker buildx ls shows all builders; the one marked with * is active.
Step 3 - Writing a platform-aware Dockerfile
Create a project directory with a small Go program that reports the platform it runs on:
mkdir ~/hello-multiarch && cd ~/hello-multiarch
nano main.go
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("Hello from %s/%s\n", runtime.GOOS, runtime.GOARCH)
}
Create the module file:
nano go.mod
module example.com/hello
go 1.24
Now create the Dockerfile:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/hello .
FROM alpine:3.21
COPY --from=build /out/hello /usr/local/bin/hello
ENTRYPOINT ["hello"]
The key lines are:
FROM --platform=$BUILDPLATFORMruns the Go compiler natively on the build host (amd64) instead of under emulation, which is much faster.TARGETOSandTARGETARCHare set automatically by Buildx for each target platform, and Go uses them to cross-compile.- The final
alpinestage has no--platform, so Buildx pulls the Alpine image for each target architecture.
Step 4 - Building and pushing the image
Log in to the registry:
docker login -u your_dockerhub_user
Build for both platforms and push the result in one command:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t your_dockerhub_user/hello:1.0 \
--push .
Buildx runs one build per platform in parallel and then pushes the images and the manifest list:
[+] Building 38.2s (17/17) FINISHED
=> [linux/amd64 build 5/5] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/hello .
=> [linux/arm64 build 5/5] RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /out/hello .
...
=> pushing manifest for docker.io/your_dockerhub_user/hello:1.0
The --push flag is needed because the docker-container builder keeps its results in the build cache, not in your local image list. The --load flag copies a result into the local Docker image store, but with the default image store it only accepts a single platform, for example --platform linux/arm64 --load.
Step 5 - Verifying the manifest
Inspect the tag in the registry:
docker buildx imagetools inspect your_dockerhub_user/hello:1.0
Name: docker.io/your_dockerhub_user/hello:1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:...
Manifests:
Name: docker.io/your_dockerhub_user/hello:1.0@sha256:...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Name: docker.io/your_dockerhub_user/hello:1.0@sha256:...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
...
You may also see entries with Platform: unknown/unknown; those are build attestations (provenance) that Buildx attaches by default, not extra images.
Run each variant. The arm64 one runs through QEMU on an amd64 host:
docker run --rm your_dockerhub_user/hello:1.0
docker run --rm --platform linux/arm64 your_dockerhub_user/hello:1.0
Hello from linux/amd64
Hello from linux/arm64
On an ARM64 server the first command prints linux/arm64 without any --platform flag, because Docker pulls the matching variant automatically.
Step 6 - Speeding up builds with a registry cache
The docker-container builder keeps a local cache, but it is lost if you remove the builder and is not shared with CI runners. Store the cache in the registry next to the image:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t your_dockerhub_user/hello:1.1 \
--cache-to type=registry,ref=your_dockerhub_user/hello:buildcache,mode=max \
--cache-from type=registry,ref=your_dockerhub_user/hello:buildcache \
--push .
mode=max also caches the layers of intermediate stages such as build. Run the command a second time and most steps show CACHED, which cuts the build time to a few seconds.
Step 7 - Adding a native ARM64 node (optional)
Emulation is fine for this small image, but compiling large C or C++ projects under QEMU can be ten times slower than native. If you have an ARM64 server with Docker, add it to the same builder over SSH so each platform builds on its own hardware:
docker buildx create --name multiarch --append --platform linux/arm64 ssh://your_user@your_arm64_server_ip
docker buildx inspect --bootstrap multiarch
The inspect output now lists two nodes. Buildx sends linux/arm64 builds to the new node and keeps amd64 builds local. The SSH user must be able to run docker on the remote server, and key-based SSH login must work without a password prompt.
Troubleshooting
exec format errorduring aRUNstep: the QEMU handler for that architecture is not registered, often after a reboot. Rerun Step 1.- An error about exporting manifest lists when using
--load: you used--loadwith several platforms. Use--push, or build one platform at a time with--load. - An error saying multi-platform builds are not supported for the
dockerdriver: the default builder is active. Rundocker buildx use multiarch. - Very slow builds: check that compile steps use
FROM --platform=$BUILDPLATFORMand cross-compile, so only the lightweight final stage runs under emulation.
Conclusion
You registered QEMU emulators, created a BuildKit builder, cross-compiled a Go program in a platform-aware Dockerfile and published a single tag that serves both amd64 and arm64 hosts, with a registry-backed build cache. As next steps, run the same docker buildx build command in your CI pipeline with the docker/setup-buildx-action and docker/setup-qemu-action actions, add linux/arm/v7 if you target older devices, or add a native ARM64 node for heavy builds.
