Stop Emulating arm64 - Native Per-Architecture Docker Builds
The multi-architecture Docker build took too long. Docker itself was not the real problem. The pipeline was building arm64 on an amd64 runner through QEMU emulation.
The fix was not to drop multi-arch support. Build amd64 on an amd64 runner, build arm64 on an arm64 runner, push both by digest, and merge them into one manifest list at the end. The resulting image tag looks the same to users. The CI path changes.
First, Clear Up the Common Misunderstanding
"If we publish a multi-arch image, does that break normal amd64 servers?" No.
A multi-arch image is a manifest list, or OCI image index, that points to multiple platform-specific image manifests under one tag. Docker's multi-platform build docs explain that when a multi-platform image is pushed, the registry stores the manifest list and the individual manifests. On pull, Docker selects the right variant for the host architecture. amd64 servers get linux/amd64; Apple Silicon Macs and arm64 VPS hosts get linux/arm64.
So multi-arch does not mean "ARM-only." It means "one tag covers multiple CPU architectures."
You can inspect that shape with:
docker buildx imagetools inspect ghcr.io/relayroom/relayroom:<tag>A healthy multi-arch tag should show both linux/amd64 and linux/arm64 manifests.
Why It Was Slow: QEMU Is Convenient, Not Cheap
Buildx can build multiple platforms in one command:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--push \
-t ghcr.io/relayroom/relayroom:<tag> .On a single amd64 runner, the amd64 build is native and the arm64 build runs through emulation. BuildKit and QEMU make this easy to start with, but Docker's docs explicitly warn that QEMU emulation can be much slower than native builds, especially for compute-heavy work like compilation and compression.
A product image with Next.js, pnpm install, native dependencies, and layer compression feels that cost quickly. If both platforms run inside one job, the slow emulated segment controls the release wall clock.
The Goal: Keep the Manifest, Change the Build Path
The desired output does not change:
- Publish
ghcr.io/relayroom/relayroom:<tag>. - Include both
linux/amd64andlinux/arm64. - Let users keep running the same
docker compose pull.
The CI internals change:
- Build amd64 on an amd64 runner.
- Build arm64 on an arm64 runner.
- Push each architecture by digest.
- Merge those digests into one final manifest list.
The Pattern: Per-Arch Build, Digest, imagetools create
Conceptually:
jobs:
build:
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- uses: docker/build-push-action@v6
id: build
with:
context: .
platforms: ${{ matrix.platform }}
push: true
tags: ghcr.io/relayroom/relayroom
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
- run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
merge:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
- run: |
refs=""
for digest_file in /tmp/digests/*; do
refs="$refs ghcr.io/relayroom/relayroom@sha256:$(basename "$digest_file")"
done
docker buildx imagetools create \
-t ghcr.io/relayroom/relayroom:${{ github.ref_name }} \
$refsReal workflows should fill in login inputs, tags, cache, release conditions, provenance, and error handling. The important part is the shape: build single-platform artifacts first, then use docker buildx imagetools create to create the final manifest list from existing source manifests. Docker's CLI docs describe imagetools create exactly that way.
Why Pass Digests Instead of Tags
If two architecture jobs push the same tag concurrently, they can race. The last writer can move the tag or produce a manifest different from what the merge job expects.
Digests are content-addressed references. The amd64 job produces one digest, and the arm64 job produces another. The merge job combines exactly those two artifacts and moves the release tag only once at the end.
Would a Self-Hosted Runner Help?
An arm64 self-hosted runner helps if it is actually arm64. It removes emulation. An amd64 self-hosted runner does not fix arm64 builds structurally; it may have better disk, CPU, or persistent layer cache, but arm64 still runs through QEMU.
For public repositories, GitHub-hosted arm64 runners may be available depending on current GitHub Actions policy and runner labels. Check the current GitHub docs before relying on a specific label or pricing model. The principle is stable: arm64 artifacts build best on arm64 CPUs.
When Cross-Compilation Is Better
For Go or Rust services with clean cross-compilation, BUILDPLATFORM, TARGETPLATFORM, TARGETOS, and TARGETARCH can work well inside a multi-stage Dockerfile. Docker documents this strategy too.
For RelayRoom's kind of image, with Node, Next.js, package-manager behavior, native dependencies, and runtime image assembly, native per-arch builds are simpler. Cross-compilation only works smoothly when every dependency agrees with the target platform. Otherwise, the build may succeed and the runtime may still carry the wrong native module.
Verify the Output
After merging, inspect the manifest:
docker buildx imagetools inspect ghcr.io/relayroom/relayroom:<tag>Both linux/amd64 and linux/arm64 should be present.
If possible, also smoke-test platform selection:
docker run --rm --platform linux/amd64 ghcr.io/relayroom/relayroom:<tag> node -p "process.arch"
docker run --rm --platform linux/arm64 ghcr.io/relayroom/relayroom:<tag> node -p "process.arch"On a host that cannot run one of those platforms natively, Docker may use emulation for the smoke test. That still checks manifest selection, but the strongest validation comes from native runners for each architecture.
Takeaway
Multi-arch solves user compatibility. Dropping it because CI is slow would punish Apple Silicon users, arm64 VPS users, and small ARM servers. The problem is not the manifest list. The problem is emulated release builds.
QEMU is useful for bootstrapping. It is a poor default for compute-heavy release pipelines. Build per architecture on native CPUs, pass digests, and merge them with imagetools create.
The final tag stays the same. Users still pull one image. The release pipeline stops pretending one CPU is another.
References
- Docker Docs, Multi-platform builds
- Docker Docs,
docker buildx imagetools create - Docker Docs, Build variables:
BUILDPLATFORMandTARGETPLATFORM - GitHub Docs, GitHub-hosted runners