Multi Platform Docker Images on Native Runners

Multi arch Docker builds crawl on one x64 runner because the arm64 stage runs under QEMU. Build each architecture natively and join them with a manifest.

Last verified:

A docker buildx build --platform linux/amd64,linux/arm64 on a single x64 GitHub Actions runner is slow because the arm64 half of that build runs under QEMU user mode emulation, which translates every guest instruction before the host CPU executes it. The fix is to build each architecture on a runner of that architecture and join the two images with a manifest list, which on WarpBuild means pairing warp-ubuntu-latest-x64-8x at $0.016 per minute with warp-ubuntu-latest-arm64-8x at $0.012 per minute. This guide covers the diagnosis, the split workflow YAML, the remote Docker builder alternative for teams that want one job, and a worked cost model against GitHub list prices.

Diagnosis

When you pass two platforms to a single buildx invocation and the builder has one node, BuildKit resolves the Dockerfile once per platform and produces two independent build graphs on that one machine. The host is x64. The arm64 graph therefore needs an aarch64 execution environment, and the usual way a GitHub Actions workflow gets one is docker/setup-qemu-action, which installs binfmt handlers so the kernel hands aarch64 binaries to a QEMU user mode interpreter.

That interpreter is where the time goes. QEMU translates aarch64 instructions into x64 instructions at run time and executes the translation. Work that is dominated by CPU pays for this on every instruction it executes: gcc and clang, rustc, the Go linker, javac, node-gyp and other native module builds, image and asset processing, minifiers, and anything that compiles a wheel from source in a pip install. Work dominated by the network pays much less, because a package download runs at the speed of the registry either way.

Three second order effects make the emulated path worse than the instruction translation alone suggests.

Both stages share one machine. BuildKit will happily run the amd64 and arm64 graphs concurrently on the same node, so they contend for the same vCPUs, the same page cache, and the same disk. The native amd64 stage slows down while the emulated arm64 stage is running, and the arm64 stage becomes the critical path for the whole job.

Cache entries are keyed per platform. A warm layer cache from the amd64 stage does nothing for the arm64 stage. If your registry cache or type=gha cache was sized around a single architecture build, the two platform build doubles the entries it wants to store and restore.

Emulated toolchains fail in ways native ones do not. The common ones worth recognizing in a log: exec format error when a binfmt handler is missing or was never registered in that job; segfaults and hangs in JIT heavy runtimes and in threading heavy builds; and Illegal instruction from libraries that probe for CPU features the interpreter does not implement. Every one of these reads like a Dockerfile bug and is actually an emulation bug, which is why teams often lose an afternoon before they suspect QEMU.

There is also a hard ceiling. A GitHub Actions job can run for up to 6 hours, and a two platform build of a large compiled service on one emulated runner can approach that limit as the codebase grows. The failure mode is a cancelled job with no artifact, usually on the day a dependency starts building from source.

Confirming the diagnosis takes one experiment. Run the same build twice on the same runner, once with --platform linux/amd64 alone and once with both platforms. The difference is what the arm64 stage costs you under emulation. If the single platform build finishes in a few minutes and the two platform build runs for tens of minutes, the emulated stage is the whole problem and no amount of runner resizing fixes it, because a bigger x64 machine still emulates.

One partial fix stays inside a single runner: cross compilation. Pin the builder stage to the native architecture with FROM --platform=$BUILDPLATFORM golang:1.24 AS build, read TARGETARCH inside that stage, and emit an aarch64 binary from a native x64 compiler, then copy it into a thin runtime stage. That keeps the compiler out of the interpreter. It requires a toolchain that cross compiles cleanly, which rules out most builds with cgo, native npm modules, or a RUN step that executes a freshly built arm64 binary during the build. When cross compilation does not apply, native runners are the answer.

Fix

Split the build. Run one job per architecture on a runner of that architecture, have each job push its image by digest without a tag, and add a short third job that writes a manifest list joining both digests under the tag your consumers pull. Nothing in the arm64 job is emulated, and the two build jobs run in parallel, so the wall clock for the pair is the slower of the two rather than the sum.

WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners. The Linux runners attach to a workflow through the runs-on label, so the split costs one matrix entry per architecture. These are the labels and rates for the three jobs in the pattern, taken from the cloud runners documentation and the pricing page:

JobRunner labelvCPURAMStorageRate per minute
amd64 buildwarp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
arm64 buildwarp-ubuntu-latest-arm64-8x832GB150GB SSD$0.012
manifest mergewarp-ubuntu-latest-x64-2x28GB150GB SSD$0.004

Both architectures come in the same five sizes, so the matrix scales without changing shape:

vCPURAMx64 labelx64 rateARM64 labelARM64 rate
28GBwarp-ubuntu-latest-x64-2x$0.004warp-ubuntu-latest-arm64-2x$0.003
416GBwarp-ubuntu-latest-x64-4x$0.008warp-ubuntu-latest-arm64-4x$0.006
832GBwarp-ubuntu-latest-x64-8x$0.016warp-ubuntu-latest-arm64-8x$0.012
1664GBwarp-ubuntu-latest-x64-16x$0.032warp-ubuntu-latest-arm64-16x$0.024
32128GBwarp-ubuntu-latest-x64-32x$0.064warp-ubuntu-latest-arm64-32x$0.048

The ARM64 rate sits below the x64 rate at every size, so the arm64 half of a split build is the cheaper half per minute as well as the faster one once emulation is gone. The latest labels track Ubuntu 24.04; pin with warp-ubuntu-2404-arm64-8x or move to warp-ubuntu-2604-arm64-8x when you want Ubuntu 26.04. The full ARM64 catalog, including the Ubuntu 26.04 labels, is on the Linux ARM64 runner page.

Two operational details to plan for before you cut over. Ubuntu 24.04 ARM64 runners set the work dir to /runner/_work, which differs from GitHub's /home/runner/work/, so any script with a hardcoded path needs an update when the job changes architecture. And workloads that need nested virtualization are x64 only, which rarely affects an image build but matters if the same workflow also runs an Android emulator. The guide to running GitHub Actions jobs on ARM64 runners covers the rest of the porting checklist.

Configuration

The workflow below builds both architectures in parallel, pushes each by digest, and merges them into a single tag. It uses warp-ubuntu-latest-x64-8x for amd64 and warp-ubuntu-latest-arm64-8x for arm64.

name: docker
on:
  push:
    branches: [main]

env:
  IMAGE: ghcr.io/${{ github.repository }}

jobs:
  build:
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 45
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/amd64
            runner: warp-ubuntu-latest-x64-8x
          - platform: linux/arm64
            runner: warp-ubuntu-latest-arm64-8x
    steps:
      - uses: actions/checkout@v4

      - name: Name the digest artifact
        env:
          PLATFORM: ${{ matrix.platform }}
        run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"

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

      - uses: docker/setup-buildx-action@v3

      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: ${{ matrix.platform }}
          outputs: type=image,name=${{ env.IMAGE }},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-${{ env.PLATFORM_PAIR }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

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

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

      - uses: docker/setup-buildx-action@v3

      - name: Create the manifest list
        working-directory: /tmp/digests
        run: |
          docker buildx imagetools create \
            -t ${{ env.IMAGE }}:latest \
            -t ${{ env.IMAGE }}:${{ github.sha }} \
            $(printf '${{ env.IMAGE }}@sha256:%s ' *)

      - name: Inspect the result
        run: docker buildx imagetools inspect ${{ env.IMAGE }}:latest

Four things in that file carry the pattern. push-by-digest=true with name-canonical=true uploads the image without claiming a tag, so the two architecture builds never race to overwrite each other. Each build job leaves an empty file named after its digest and uploads it, which is the cheapest way to pass a digest between jobs. The merge job downloads every digests-* artifact into one directory and expands the filenames into the imagetools create arguments. fail-fast: false keeps a broken arm64 build from cancelling a healthy amd64 build, which makes the failure easier to read.

Note what is missing: there is no docker/setup-qemu-action step anywhere, because no job builds a foreign architecture.

Alternative: remote Docker builders

If you would rather keep one build job, run the build off the runner. Remote Docker builders are dedicated build VMs with a persistent layer cache, selected per builder profile, and multi architecture builds are supported directly. Create a profile on the Docker Builders page, enable both architectures on it, and point the WarpBuild build and push action at it:

name: docker
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

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

      - name: Build and push
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ghcr.io/${{ github.repository }}:latest
          profile-name: "multi-arch-builder"
          api-key: ${{ secrets.WARPBUILD_API_KEY }} # Not required on WarpBuild runners
          timeout: 600000

Warpbuilds/build-push-action is a drop in replacement for docker/build-push-action, so the context, push, tags, and platforms inputs keep their meaning. Remove docker/setup-buildx-action when it was only there to create a builder, since the action wires up the remote builder itself. Remote Docker builders work with both WarpBuild runners and non-WarpBuild runners; the api-key input is what authenticates the job when the runner is not a WarpBuild runner. If you need custom build steps rather than a single action, Warpbuilds/docker-configure@v1 provisions the builder and exposes its details for your own commands, and the same assignment flow is available from the API and the CLI.

Profile sizing and rates come from the Docker builders documentation:

Profile sizeDiskRate per minuteArchitectures
16 vCPU, 32GB RAM100GB$0.06amd64, arm64, multi
32 vCPU, 64GB RAM200GB$0.12amd64, arm64, multi
64 vCPU, 128GB RAM200GB$0.24amd64, arm64, multi

Multi architecture and arm64 profiles top out at 64 vCPU. The 96 and 192 vCPU sizes, from $0.36 to $0.88 per minute, are amd64 only. Billing is per session, measured from when the builder action starts until the job completes, and a multi architecture build creates one session per architecture because each architecture runs on its own builder instance. The builder cache resets after 10 days without use, and an exec format error from this path almost always means the profile is missing one of the two architectures.

CI observability is the practical one here, since it correlates system metrics from the runner agent with GitHub Actions job logs, so you can see whether the arm64 job is CPU bound at its current size before you move it from 8 vCPU to 16 vCPU.

Cost or Time Model

Measure your own build first: run the single platform amd64 build and the two platform build on the same runner, then run each architecture natively. The model below uses one set of example durations so the arithmetic is visible; substitute yours.

Assume the emulated path runs 42 billed minutes on one warp-ubuntu-latest-x64-8x runner. Split natively, the amd64 job takes 9 minutes, the arm64 job takes 10 minutes, and the merge job takes 1 minute on a 2 vCPU runner. WarpBuild rates are from the pricing page.

PathArithmeticCost per buildWall clock
Emulated, one x64 runner42 min x $0.016$0.67242 min
Native split, three jobs(9 x $0.016) + (10 x $0.012) + (1 x $0.004)$0.26811 min

The split path costs $0.404 less per build and returns 31 minutes of wall clock, because the two build jobs run in parallel and the merge job adds one minute. At 400 image builds per month that is $268.80 against $107.20, a difference of $161.60 per month, and 12,400 minutes of pipeline time removed, roughly 207 hours.

The same comparison on GitHub-hosted runners, using GitHub's published per-minute list prices from the actions minute multipliers reference and the GitHub pricing page, both checked on 2026-08-13: an 8 vCPU Linux x64 runner is $0.022 per minute, an 8 vCPU Linux ARM64 runner is $0.014 per minute, and the standard ubuntu-latest runner is $0.006 per minute.

PathArithmeticCost per buildMonthly at 400 builds
GitHub-hosted, emulated on 8 vCPU x6442 min x $0.022$0.924$369.60
GitHub-hosted, native split(9 x $0.022) + (10 x $0.014) + (1 x $0.006)$0.344$137.60
WarpBuild, emulated on 8 vCPU x6442 min x $0.016$0.672$268.80
WarpBuild, native split(9 x $0.016) + (10 x $0.012) + (1 x $0.004)$0.268$107.20

Two readings of that table. The architecture change is the larger lever: dropping emulation moves the bill from $369.60 to $137.60 on GitHub-hosted runners without changing provider. The rate change stacks on top of it at the same vCPU count on both sides: $0.016 per minute against $0.022 on 8 vCPU x64 is 27 percent lower list price, and $0.012 against $0.014 on 8 vCPU ARM64 is 14 percent lower, both checked on 2026-08-13.

The remote Docker builder path bills differently, so model it separately. The GitHub Actions job and the builder session are two independent resources and both are charged. With a 16 vCPU multi architecture profile at $0.06 per minute, a cold build with a 9 minute session per architecture and a 9 minute driver job on warp-ubuntu-latest-x64-4x costs (2 x 9 x $0.06) + (9 x $0.008) = $1.152. Once the persistent layer cache is warm and the build settles at 3 minutes, the same build costs (2 x 3 x $0.06) + (3 x $0.008) = $0.384.

That path is worth its price when the layer cache hit rate is high and the image build is the whole job, since the cache lives on the builder rather than being restored over the network on every run. When the job also runs tests, lint, and packaging around the image build, the native split usually prices better, because you are paying runner minutes you already needed.

One more variable worth pricing: where the compute runs. BYOC runs on AWS, GCP, and Azure, which keeps the same warp- labels and the same split workflow while the machines live in your own cloud account. Teams already committed to cloud spend often model that path alongside the two above.

To rerun any of this with your own numbers, take the billed minutes per architecture from your GitHub usage report, multiply by the rate at your size in the tables above, and compare against the emulated total you measured in the diagnosis step. For the wider Docker picture on GitHub Actions, including cache strategy and registry choice, see the Docker builds on GitHub Actions page.

FAQ

Why is buildx --platform linux/amd64,linux/arm64 slow on a single x64 runner?

The arm64 half of the build runs under QEMU user mode emulation, so every guest instruction is translated before the host CPU executes it. Compilers, linkers, and native module builds pay that translation on every instruction, which is why the arm64 stage dominates the wall clock.

Do I still need docker/setup-qemu-action after splitting the build?

No. Each job builds only its own architecture on a runner of that architecture, so no binfmt handler is involved. Keep setup-qemu-action only on workflows where one machine still has to produce both architectures.

How do two separate architecture images end up under one tag?

Each build job pushes by digest with push-by-digest=true, and a final job runs docker buildx imagetools create to write a manifest list pointing at both digests. A docker pull of that tag then resolves to the variant matching the client architecture.

Can I keep the multi architecture build in one job?

Yes, with remote Docker builders. Set platforms to linux/amd64,linux/arm64 on Warpbuilds/build-push-action and point it at a builder profile that has both architectures enabled. Each architecture runs on a separate builder instance, so the build bills as one session per architecture.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.