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-plugin package. Your user should be able to run docker commands.
  • An account on a container registry. This guide uses Docker Hub; replace your_dockerhub_user with 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:

MethodHow it worksWhen to use it
QEMU emulationRuns foreign binaries through binfmt_miscSimplest; fine for small builds, slow for heavy compilation
Cross-compilationCompiler on the build host targets another CPUFast; works for Go, Rust, and other toolchains that support it
Native nodesOne builder with an amd64 and an arm64 nodeFastest 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=$BUILDPLATFORM runs the Go compiler natively on the build host (amd64) instead of under emulation, which is much faster.
  • TARGETOS and TARGETARCH are set automatically by Buildx for each target platform, and Go uses them to cross-compile.
  • The final alpine stage 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 error during a RUN step: 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 --load with several platforms. Use --push, or build one platform at a time with --load.
  • An error saying multi-platform builds are not supported for the docker driver: the default builder is active. Run docker buildx use multiarch.
  • Very slow builds: check that compile steps use FROM --platform=$BUILDPLATFORM and 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.