Moving ARM64 Docker Builds Off QEMU

A buildx arm64 build on an x64 GitHub Actions runner is slow because QEMU translates every instruction. Move it to a native ARM64 runner or builder.

Last verified:

A docker buildx build --platform linux/arm64 on an x64 GitHub Actions runner is slow because the arm64 half of the build runs under QEMU user mode emulation, which translates aarch64 instructions into x64 instructions before the host CPU executes them and marshals every syscall between the two ABIs. Removing the emulation means running the build where aarch64 is the native instruction set: either a native runner label such as warp-ubuntu-latest-arm64-8x at $0.012 per minute, or a remote Docker builder profile with arm64 enabled.

This guide covers what the interpreter is doing during a buildx run, which Dockerfile steps absorb the cost, the before and after workflow diff for both routes, the builder profile constraints that shape the choice, and a cost model with the assumptions written down. Joining two per-architecture images into one tag with a manifest list belongs to the multi-platform Docker image guide; this page is about getting the interpreter out of the build.

Diagnosis

What the emulation layer does inside a buildx run

docker/setup-qemu-action runs a privileged container that writes entries into /proc/sys/fs/binfmt_misc. Each entry tells the kernel that a binary whose ELF header says aarch64 should be handed to qemu-aarch64-static rather than executed directly. That is the whole mechanism. Nothing about the runner changes; the kernel simply gains a rule for foreign binaries.

BuildKit then resolves the Dockerfile for linux/arm64 and builds that graph on the x64 machine. It pulls arm64 base images, unpacks them into a snapshot, and executes each RUN step inside a container whose root filesystem is full of aarch64 binaries. Every one of those binaries hits the binfmt rule and runs as translated code.

The translation works a block at a time. QEMU reads a run of aarch64 instructions, compiles it into x64 instructions, caches the translated block, and jumps into it. A hot loop pays the translation once and then runs the cached form, so a long compile is not as bad as a per-instruction model would suggest, and it is still well short of native. Syscalls follow a different path: the interpreter catches each one, converts the arm64 ABI arguments into the host ABI, and forwards it to the x64 kernel. A process that makes many small syscalls, such as a linker walking thousands of object files or a package manager unpacking an archive, pays that marshalling on every call.

Two things stay x64 no matter how faithful the translation is. The kernel is the host kernel, so /proc/cpuinfo, core counts, and CPU feature detection describe the x64 machine. And the wall clock comes from the host, so a build step that scales its parallelism from detected cores makes its decision on x64 information while running aarch64 code.

Which steps carry the cost

The exposure is uneven, which is why two projects on the same runner size can see very different emulated build times. Work that BuildKit performs on the host is untouched. Work that runs inside the target root filesystem is translated.

Build stepExecuted byEmulation exposure
FROM pull and unpackBuildKit on the hostNone. Bytes move at host speed.
COPY and ADD from the build contextBuildKit on the hostNone.
Layer export and registry pushBuildKit on the hostNone.
RUN curl, RUN git cloneNetwork bound inside the containerLow. The wait dominates.
RUN apt-get installMixedDownload is native speed; dpkg unpack and maintainer scripts are translated.
RUN compile steps: gcc, clang, rustc, javac, go build with cgoTranslatedHighest. CPU bound from start to finish.
Link steps: ld, lld, arTranslatedHigh, and syscall heavy on top of the translation.
RUN npm ci with native modules through node-gypTranslatedHigh. Each module compiles inside the interpreter.
RUN pip install where no aarch64 wheel existsTranslatedHigh. The wheel is built from source under the interpreter.
Test suites executed during the image buildTranslatedHigh, and the least predictable.

A Dockerfile that is mostly COPY of prebuilt assets onto a slim base barely notices emulation. A Dockerfile that compiles a service from source spends nearly all of its time in the interpreter.

Failure modes that read like Dockerfile bugs

Four log signatures point at emulation rather than at your build.

exec format error at the top of a step means the kernel had no binfmt handler for that binary at the moment it ran. The usual causes are a setup-qemu-action step placed after the build, scoped to a different job, or skipped by a conditional.

Illegal instruction or SIGILL comes from code that probed for a CPU feature and got an answer the interpreter cannot honor. Crypto libraries and codecs with runtime dispatch land here most often.

qemu: uncaught target signal 11 wraps a segfault that happened inside the translated program. JIT-heavy runtimes and threading-heavy builds produce these under emulation while running clean on real hardware.

A step that hangs with no output is the fourth. Emulated builds change timing enough to expose races that never fire natively, and a lock ordering bug that takes a real machine a month to hit can become reproducible under translation.

Confirming it in one run

Add a temporary step ahead of the build and read three lines.

      - name: report architecture
        run: |
          uname -m
          ls /proc/sys/fs/binfmt_misc/ | grep -i qemu || echo "no qemu handlers"
          docker buildx inspect --bootstrap | grep -i platforms

On an emulated job uname -m prints x86_64, the binfmt listing shows a qemu-aarch64 entry, and the buildx platform list carries linux/arm64 without the asterisk that marks a native platform. On a native ARM64 runner uname -m prints aarch64 and no handler is involved.

Then measure. Run the same Dockerfile twice on the same x64 runner, once with --platform linux/amd64 and once with --platform linux/arm64, and record both durations. The gap is what the interpreter costs you, and it is the number the cost model below wants. Resizing the x64 runner does not close that gap, because a larger x64 machine still translates every instruction.

Two more constraints worth checking before you plan the fix. Layer cache entries are keyed per platform, so a warm amd64 cache does nothing for an arm64 build and a workflow that builds both stores two sets of entries. And a GitHub Actions job runs for at most 6 hours, which an emulated build of a large compiled service can approach once a dependency starts building from source.

Fix

Two routes remove the interpreter. Both keep the same Dockerfile.

Route A: run the job on a native ARM64 runner. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the ARM64 runners attach through the runs-on label. The edit is to delete the QEMU setup step and change one label. Everything in the job, including the tests and packaging steps around the image build, then executes on aarch64 hardware.

These are the Linux ARM64 labels and rates, from the cloud runners documentation and the pricing page:

LabelvCPURAMStorageRate per minutePinned alias
warp-ubuntu-latest-arm64-2x28GB150GB SSD$0.003warp-ubuntu-2404-arm64-2x
warp-ubuntu-latest-arm64-4x416GB150GB SSD$0.006warp-ubuntu-2404-arm64-4x
warp-ubuntu-latest-arm64-8x832GB150GB SSD$0.012warp-ubuntu-2404-arm64-8x
warp-ubuntu-latest-arm64-16x1664GB150GB SSD$0.024warp-ubuntu-2404-arm64-16x
warp-ubuntu-latest-arm64-32x32128GB150GB SSD$0.048warp-ubuntu-2404-arm64-32x

Ubuntu 26.04 ships under warp-ubuntu-2604-arm64-8x and the rest of that series at the same rates. Pin the alias in the runs-on label when you want the image version to change on your schedule rather than when latest moves. The full label list, including the preinstalled tooling, is on the Linux ARM64 runner page.

One porting detail applies to the Ubuntu 24.04 ARM64 image: its work dir is /runner/_work, which differs from GitHub's /home/runner/work/, so a script with a hardcoded runner path needs to read $GITHUB_WORKSPACE instead. Workloads that need nested virtualization stay on x64.

Route B: keep the job where it is and send the build to an arm64 builder profile. Remote Docker builders are one of the WarpBuild product surfaces alongside snapshot runners, CI observability, an MCP server, and the Action Debugger. A builder profile maps to one dedicated builder VM with a persistent layer cache on local disk, and the GitHub Actions job becomes a thin driver that ships the context and streams the log. The build runs on native arm64 hardware inside the builder, so no interpreter is involved even though the runner is x64.

Pick between them on the shape of the job.

  • The job is ARM64 work end to end, including tests and packaging: Route A. One label change, one billed resource.
  • The job also compiles or tests x64 artifacts and the image build is one step among several: Route B, so the runner stays x64 while the image build goes native.
  • One job has to publish both architectures under one tag: Route B with a multi-arch profile, or the per-architecture split covered in the multi-platform Docker image guide.
  • The image build is the whole job and rebuilds are frequent with a high layer reuse rate: Route B, because the layer cache lives on the builder disk instead of being restored over the network each run.

There is a third option that keeps the build on the x64 runner: cross compile, so a native x64 toolchain emits an aarch64 binary that a thin runtime stage copies in. That works when the toolchain cross compiles cleanly and breaks on cgo, native npm modules, and any RUN step that executes a freshly built aarch64 binary during the build. The tradeoffs are worked through in cross compiling compared with native ARM64 builds.

Generally available Linux runners do not have plan-level concurrency caps, so an x64 and ARM64 matrix is not serialized by the WarpBuild plan.

Configuration

Here is the workflow most teams start from. The runner is x64, the QEMU step installs the binfmt handlers, and the build targets arm64.

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

jobs:
  image:
    runs-on: ubuntu-latest
    timeout-minutes: 90
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3
        with:
          platforms: arm64

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

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

      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/arm64
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

Route A: native ARM64 runner

The diff is two edits. Change the label, delete the QEMU step.

 jobs:
   image:
-    runs-on: ubuntu-latest
+    runs-on: warp-ubuntu-latest-arm64-8x
     timeout-minutes: 90
     steps:
       - uses: actions/checkout@v4

-      - uses: docker/setup-qemu-action@v3
-        with:
-          platforms: arm64
-
       - uses: docker/setup-buildx-action@v3

Nothing below that line changes. platforms: linux/arm64 is now the native platform of the machine, so buildx builds it directly. Drop timeout-minutes: 90 back to something that reflects the new duration once you have measured a few runs, so a genuinely stuck build fails fast again.

Add the architecture to any cache key in the workflow. Cache entries are keyed by a hash that includes the runner OS, so entries written on an x64 runner will not restore on the ARM64 one, and mixing them wastes storage on entries neither fleet reads.

      - uses: actions/cache@v4
        with:
          path: ~/.cargo/registry
          key: cargo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/Cargo.lock') }}

Route B: arm64 builder profile

Create a builder profile in the Docker builders dashboard, enable arm64 on it, then swap the build action. The WarpBuild action configures the remote builder for you, so the local buildx setup step comes out as well.

 jobs:
   image:
-    runs-on: ubuntu-latest
+    runs-on: warp-ubuntu-latest-x64-2x
     timeout-minutes: 90
     steps:
       - uses: actions/checkout@v4

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

-      - uses: docker/build-push-action@v6
+      - uses: Warpbuilds/build-push-action@v6
         with:
           context: .
           platforms: linux/arm64
           push: true
           tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
+          profile-name: arm64-images

On a WarpBuild runner the action authenticates automatically. From a GitHub-hosted runner or another platform, add api-key: ${{ secrets.WARPBUILD_API_KEY }}. To produce both architectures from this one job, enable both on the profile and set platforms: linux/amd64,linux/arm64.

cache-from and cache-to come out of the workflow entirely on this route. The builder holds the layer cache on its own disk, so there is nothing to export to a registry or to the Actions cache between runs.

Builder profile constraints

Four facts from the Docker builders documentation shape the sizing and the bill, and the remote Docker builder page carries the full size list.

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

First, arm64 and multi-arch profiles cap at 64 vCPU. The 96 and 192 vCPU sizes, from $0.36 to $0.88 per minute, exist for amd64-only profiles, so an ARM64 build tops out at the 64 vCPU row above.

Second, a multi-arch build runs one session per architecture, because each architecture runs on its own builder instance. Two sessions bill independently for the same profile, and both stay alive until the post-action steps finish.

Third, billing is per session, measured from when the builder action starts until the job completes, and concurrent jobs sharing one profile share one session billed from the first job's start to the last job's completion. Fourth, the runner and the builder are two separate resources and both are charged.

Two operational notes to file away. The profile cache resets automatically after 10 unused days, so the first build after a quiet period is a cold one. And exec format error on this route almost always means the profile is missing the architecture the build asked for.

Cost or Time Model

Assumptions, stated so you can substitute your own numbers from the two-run measurement in the diagnosis section:

  • One Dockerfile producing a single linux/arm64 image for a compiled service, where the compile and link steps dominate.
  • Example durations: 36 billed minutes emulated on an 8 vCPU x64 runner, 8 billed minutes native on an 8 vCPU ARM64 runner. These are placeholders that make the arithmetic visible. Measure your own; the ratio depends entirely on how much of your Dockerfile is CPU bound inside the target rootfs.
  • 300 image builds per month.
  • WarpBuild rates from the pricing page, billed per minute.
PathArithmeticCost per buildMonthly at 300 builds
Emulated on warp-ubuntu-latest-x64-8x36 x $0.016$0.576$172.80
Native on warp-ubuntu-latest-arm64-8x8 x $0.012$0.096$28.80

Two levers move independently in that table. The architecture change removes 28 billed minutes per build. The rate change is worth $0.004 per minute at the 8 vCPU size, since the ARM64 rate sits below the x64 rate at every size in the catalog above.

Against GitHub-hosted list prices

GitHub publishes its per-minute rates in the Actions minute multipliers reference, checked on 2026-08-13, with shapes from the GitHub-hosted runners reference. Same shape on both sides:

ShapeGitHub-hosted ARM64 labelGitHub rateWarpBuild labelWarpBuild rateDifference
2 vCPU, 8 GBubuntu-24.04-arm$0.005warp-ubuntu-latest-arm64-2x$0.00340 percent lower list price
4 vCPU, 16 GB4-core Linux ARM64 larger runner$0.008warp-ubuntu-latest-arm64-4x$0.00625 percent lower list price
8 vCPU, 32 GB8-core Linux ARM64 larger runner$0.014warp-ubuntu-latest-arm64-8x$0.01214 percent lower list price
16 vCPU, 64 GB16-core Linux ARM64 larger runner$0.026warp-ubuntu-latest-arm64-16x$0.0248 percent lower list price
32 vCPU, 128 GB32-core Linux ARM64 larger runner$0.050warp-ubuntu-latest-arm64-32x$0.0484 percent lower list price

The GitHub ARM64 rate on private repositories was checked on 2026-08-13 and the 2 vCPU row uses that private-repository shape, which is what paying teams run. Applying those rates to the same example durations: the emulated build on an 8 vCPU x64 GitHub-hosted larger runner at $0.022 per minute costs 36 x $0.022 = $0.792 per build, or $237.60 per month, and the native build on the 8 vCPU GitHub-hosted ARM64 larger runner costs 8 x $0.014 = $0.112 per build, or $33.60 per month. Removing the emulation is the larger lever on either provider; the per-minute rate stacks on top of it.

The builder route, priced

The builder route bills two resources, so model it on its own. Take a 16 vCPU arm64 profile at $0.06 per minute and a driver job on warp-ubuntu-latest-x64-2x at $0.004 per minute.

ScenarioArithmeticCost per build
Cold builder, 8 minute session and 8 minute job(8 x $0.06) + (8 x $0.004)$0.512
Warm layer cache, 2 minute session and 2 minute job(2 x $0.06) + (2 x $0.004)$0.128
Multi-arch, two 8 minute sessions and an 8 minute job(2 x 8 x $0.06) + (8 x $0.004)$0.992

The third row is the one to check against your own numbers before committing, since a multi-arch build creates one session per architecture and both bill independently. When the job also runs tests and packaging around the image, Route A usually prices better, because you are paying for runner minutes you needed anyway.

To rerun this with real inputs: take your emulated duration and your native duration from the two-run experiment, multiply each by the rate at your size, and multiply by your monthly build count. Every cost number on this page carries a source link and a checked-on date so you can verify it before you plan around it.

SSO is available for a flat $250 per month, whatever the user count, and it is the one line on this page that does not move with usage.

FAQ

What is QEMU actually doing during a buildx arm64 build?

docker/setup-qemu-action writes binfmt_misc entries so the kernel routes any aarch64 binary to qemu-aarch64-static. BuildKit then runs each RUN step of the arm64 graph in a container full of aarch64 binaries, so the interpreter translates blocks of aarch64 instructions into x64 instructions and marshals every syscall across the two ABIs.

Which Dockerfile steps get slower under emulation and which do not?

FROM pulls, COPY, and ADD are executed by BuildKit on the host and are unaffected. The cost lands on RUN steps that burn CPU inside the target rootfs: compilers, linkers, node-gyp rebuilds, pip installs that build a wheel from source, and test suites run during the image build. Steps that mostly wait on the network stay close to their native duration.

Do I need a native ARM64 runner, or is an arm64 builder profile enough?

Either removes the interpreter. Use a warp-ubuntu-latest-arm64 runner when the whole job is ARM64 work. Use an arm64 or multi-arch builder profile when the job also does x64 work, or when one job has to produce both architectures. Note that arm64 and multi-arch profiles cap at 64 vCPU and that a multi-arch build runs one session per architecture.

Why does the build still fail with exec format error after I remove setup-qemu-action?

On a native ARM64 runner that message means something in the build is still x64, usually a binary baked into the context or downloaded by a step with a hardcoded amd64 URL. On a remote Docker builder it usually means the builder profile does not have the architecture you asked for enabled, so enable both architectures on the profile.

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.