Moving GitHub Actions Builds to Native ARM64

Build ARM64 artifacts natively in GitHub Actions by removing the QEMU emulation step and sending the job to a WarpBuild native ARM64 runner label.

Last verified:

GitHub Actions builds ARM64 artifacts natively when the job runs on an ARM64 machine, which removes the QEMU translation layer that docker/setup-qemu-action installs on an x64 runner. WarpBuild registers native Linux ARM64 runners under labels such as warp-ubuntu-latest-arm64-8x, so the migration is two edits: delete the QEMU setup step and change runs-on.

This guide covers the migration itself. The full label and price list lives on the Linux ARM64 runner catalog, and the joining of per-architecture builds into one image tag lives in the multi-platform Docker build guide.

Diagnosis

Emulated ARM64 builds are easy to miss because they succeed. The workflow is green, the image manifest says linux/arm64, and the only visible cost is time. Start by proving that a job is emulating, then decide whether it is worth moving.

Symptoms that point at emulation

A job is a candidate when several of these hold at once.

  • The job includes a docker/setup-qemu-action step, and a later step passes platforms: linux/arm64 while runs-on is an x64 label such as ubuntu-latest.
  • One stage dominates the wall clock. Checkout, dependency downloads, and shell glue take about as long as they do on an x64-only job, while cargo build, go build with cgo enabled, npm rebuild, pip install of packages with no ARM64 wheel, and Gradle or Maven steps that compile native code stretch out.
  • Jobs trip timeout-minutes, or brush the GitHub Actions job limit of 6 hours, on branches where the same steps used to finish comfortably.
  • Native dependency behaviour drifts from a real ARM64 machine. Binaries that use less common instructions abort with SIGILL, test suites that exercise atomics or thread scheduling produce different flake patterns, and build scripts that read /proc/cpuinfo report the x64 host while the userspace they run in is ARM64.
  • exec format error appears at the top of a step. That message means the kernel had no binfmt handler for the ARM64 binary at the moment it ran, which happens when the QEMU step was skipped, ordered after the build, or scoped to a different job.

Confirm it in one step

Add a temporary step to the suspect job and read three lines of output.

      - 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 || true

On an emulated job, uname -m prints x86_64 while the container build targets linux/arm64, and /proc/sys/fs/binfmt_misc/ lists a qemu-aarch64 handler that the setup action registered. On a native ARM64 runner, uname -m prints aarch64 and no QEMU handler is needed for the build to run.

Why the minutes land where they do

QEMU user-mode emulation translates guest instructions to host instructions while syscalls pass through to the host kernel. That split explains the symptom pattern. Steps bound by network or disk look close to normal, because the host does that work at host speed. Steps bound by CPU carry the translation cost on every instruction, so compilers, linkers, and test binaries absorb almost all of it.

The same split explains the correctness surprises. Emulation reproduces the instruction set, and the surrounding environment stays x64: CPU feature detection, core counts, memory reporting, and timing all come from the host. Code that branches on those signals takes a different path than it will on the ARM64 machine you ship to, which is a weak place to be when the artifact is a production image.

Decide what actually moves

Not every emulated job is worth migrating on day one. Rank candidates by emulated minutes per month, which is job frequency multiplied by the length of the emulated stage. A nightly ARM64 image build is a small prize. A per pull request build that compiles a Rust or Go binary for ARM64 on every push is the one to move first. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, so a repository can keep its x64 jobs where they are and move only the ARM64 work.

Fix

The change is small enough to review in one pull request per repository.

The workflow diff

Here is a typical emulated image build.

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

jobs:
  build-arm64:
    runs-on: ubuntu-latest
    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 }}-arm64

The migration removes four lines and changes one.

 jobs:
   build-arm64:
-    runs-on: ubuntu-latest
+    runs-on: warp-ubuntu-latest-arm64-8x
     steps:
       - uses: actions/checkout@v4
-      - uses: docker/setup-qemu-action@v3
-        with:
-          platforms: arm64
       - uses: docker/setup-buildx-action@v3

After the edit the job reads like an ordinary Linux build. The platforms: linux/arm64 line stays, and it now names the host platform, so buildx builds directly instead of routing through a binfmt handler.

jobs:
  build-arm64:
    runs-on: warp-ubuntu-latest-arm64-8x
    steps:
      - uses: actions/checkout@v4
      - 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 }}-arm64

Both architectures, each on its own machine

Most teams that emulate ARM64 publish a multi-architecture image. A matrix sends each architecture to a runner of that architecture, and both legs run in parallel.

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/amd64
            runner: warp-ubuntu-latest-x64-8x
            suffix: amd64
          - platform: linux/arm64
            runner: warp-ubuntu-latest-arm64-8x
            suffix: arm64
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - 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: ${{ matrix.platform }}
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}-${{ matrix.suffix }}

A final job joins the two tags into one manifest. The digest handling and the manifest job are covered in the multi-platform Docker build guide.

Compiled languages without containers

Container builds are the loudest case, and the same move applies to plain compile and test jobs. A Go service that enables cgo, or that runs its test suite against ARM64 behaviour, gets a native runner and a normal toolchain.

  go-build:
    runs-on: warp-ubuntu-latest-arm64-4x
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.24"
      - run: go build ./...
      - run: go test -race ./...

When cgo is off and the test suite does not need ARM64 behaviour, cross-compiling with GOARCH=arm64 on an x64 runner is still a reasonable answer for the build artifact, and it leaves the tests running on the architecture you ship. The Go on GitHub Actions solution page covers that split.

Roll it out

Move one job in one repository first and let it run for a week of normal traffic. Compare the job duration against the emulated baseline in the Actions run history, then widen. The number that matters for your rollout is the one from your own pipeline, not a general figure from someone else's.

When a migrated job fails in a way the logs do not explain, the Action Debugger pauses the workflow and opens an SSH session on the runner so you can inspect the ARM64 machine directly, and CI observability correlates runner system metrics with the job logs.

Configuration

ARM64 labels, sizes, and rates

Five sizes carry the Ubuntu 24.04 ARM64 image under the latest alias, and the same sizes exist for Ubuntu 26.04 under warp-ubuntu-2604-arm64-<size>. GitHub publishes its own per-minute rates for GitHub-hosted Linux ARM64 runners on the Actions minute multipliers reference, checked on 2026-08-13.

Runner labelAliasvCPURAMStorageWarpBuild per minuteGitHub-hosted Linux arm64 same core count
warp-ubuntu-latest-arm64-2xwarp-ubuntu-2404-arm64-2x28GB150GB SSD$0.003$0.005
warp-ubuntu-latest-arm64-4xwarp-ubuntu-2404-arm64-4x416GB150GB SSD$0.006$0.008
warp-ubuntu-latest-arm64-8xwarp-ubuntu-2404-arm64-8x832GB150GB SSD$0.012$0.014
warp-ubuntu-latest-arm64-16xwarp-ubuntu-2404-arm64-16x1664GB150GB SSD$0.024$0.026
warp-ubuntu-latest-arm64-32xwarp-ubuntu-2404-arm64-32x32128GB150GB SSD$0.048$0.050

The ARM64 images for Ubuntu 22.04 were deprecated on March 31, 2025, so pin to a 24.04 or 26.04 label rather than carrying an old tag forward. Full platform coverage and the size list for every platform sit in the cloud runners documentation.

The work dir difference

The Ubuntu 24.04 ARM64 image sets the work dir to /runner/_work, which differs from GitHub's /home/runner/work/ on the equivalent instance. Anything that hardcodes the GitHub path breaks on the first ARM64 run: shell scripts, container volume mounts, absolute paths in tool configuration, and coverage or lint tools that record file paths.

The fix is to read the path from the environment.

      - name: use the workspace path
        run: |
          test -d "$GITHUB_WORKSPACE"
          docker run --rm -v "$GITHUB_WORKSPACE:/src" -w /src alpine:3 ls -1

Inside uses: steps and expressions, the same value is available as the github.workspace context. Grep the repository for /home/runner/work before the migration and you will usually find every offender in one pass.

Tooling on the ARM64 image

The Ubuntu 24.04 ARM64 runners are compatible with GitHub's Ubuntu 24.04 ARM64 runners, and the tooling list is published in the partner runner images reference for the ARM Ubuntu 24 image. Ubuntu 26.04 ARM64 tracks the upstream Ubuntu 26.04 ARM64 image readme. The preinstalled software documentation maps every image to its upstream list, which is the fastest way to check whether a tool your workflow assumes is present before you flip a label.

Two checks are worth doing up front. Third-party actions that ship prebuilt binaries sometimes publish x64 assets only, so read the action release page before you assume it runs. Container jobs and service containers need an arm64 variant of the image they name, which most official images publish and some internal base images do not.

Cache keys do not cross architectures

Cache entries are identified by a version hash covering the compression tool used on that runner OS and the cached paths, so an entry written on warp-ubuntu-latest-x64-8x does not restore on warp-ubuntu-latest-arm64-8x. Plan for one cold run per key after the switch, and put the architecture into the key so the two fleets stop invalidating each other.

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

Cache for build artifacts and dependencies is enabled by default on Linux runners, including the ARM64 sizes.

Work that stays on x64

Nested virtualization and /dev/kvm access are available on Linux x64 runners through the nested-virtualization.enabled=true label, and are unavailable on ARM64 runners today. Android emulator jobs and anything else that boots a VM inside the runner stay on an x64 label such as warp-ubuntu-latest-x64-8x, while the ARM64 compile jobs move.

Running ARM64 inside your own cloud

Teams with data residency or network placement requirements can run the same migration on their own account. BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS, so the runner fleet is declared alongside the rest of your infrastructure. The workflow edit is identical, because the label is still what routes the job.

Cost or Time Model

The migration changes two numbers at once: the per-minute rate and the number of minutes the job needs. Model them separately so the decision does not rest on a single blended figure.

Assumptions

  • 1,200 workflow runs per month, which is 60 runs per weekday across 20 weekdays.
  • One ARM64 build job per run.
  • Emulated path: warp-ubuntu-latest-x64-8x at $0.016 per minute, with docker/setup-qemu-action in the job.
  • Native path: warp-ubuntu-latest-arm64-8x at $0.012 per minute.
  • Billing is per minute of runner time.
  • E is the emulated job length in minutes and N is the native job length in minutes. Both are values you measure in your own pipeline. The values used below are placeholders that show the arithmetic.

The rate side

At every size, the ARM64 label carries a lower per-minute rate than the x64 label with the same vCPU count.

Sizex64 labelx64 per minuteARM64 labelARM64 per minuteDifference per minute
2xwarp-ubuntu-latest-x64-2x$0.004warp-ubuntu-latest-arm64-2x$0.003$0.001
4xwarp-ubuntu-latest-x64-4x$0.008warp-ubuntu-latest-arm64-4x$0.006$0.002
8xwarp-ubuntu-latest-x64-8x$0.016warp-ubuntu-latest-arm64-8x$0.012$0.004
16xwarp-ubuntu-latest-x64-16x$0.032warp-ubuntu-latest-arm64-16x$0.024$0.008
32xwarp-ubuntu-latest-x64-32x$0.064warp-ubuntu-latest-arm64-32x$0.048$0.016

For a list-price anchor outside WarpBuild: warp-ubuntu-latest-arm64-8x is $0.012 per minute against $0.014 per minute for the GitHub-hosted Linux arm64 8-core runner, a 14 percent lower list price (GitHub Actions minute multipliers, checked on 2026-08-13). At the 2 vCPU size the same comparison is $0.003 against $0.005, a 40 percent lower list price.

The minutes side

Monthly cost on the emulated path is 1200 * E * $0.016, which is $19.20 * E. Monthly cost on the native path is 1200 * N * $0.012, which is $14.40 * N.

Emulated minutes EEmulated monthly costNative minutes NNative monthly costSame N on GitHub-hosted arm64 8-core at $0.014
10$192.005$72.00$84.00
20$384.008$115.20$134.40
30$576.0012$172.80$201.60
45$864.0020$288.00$336.00

Read the two halves independently. The left half is what the pipeline costs today at whatever E you measure. The right half is what it costs after the move at whatever N you measure. Fill both from your own run history rather than from the placeholder rows.

The time model is the same arithmetic without the rate. At E of 30 the pipeline consumes 36,000 runner minutes per month; at N of 12 it consumes 14,400. Queue time sits on top of both and is unaffected by the architecture change.

Break-even

Setting 0.016 * E equal to 0.012 * N gives N = 1.33 * E. The native path costs less on the invoice unless the native job needs more than four minutes for every three emulated minutes, which gives the rate difference room to absorb a migration that lands slower than expected on a specific workload. Anything better than that break-even shows up as a lower bill and a shorter feedback loop at the same time.

One more input belongs in the model. The full rate sheet, including cache and snapshot line items, is on the pricing page.

FAQ

Do I still need docker/setup-qemu-action after moving to native ARM64 runners?

No. The QEMU step registers binfmt handlers so an x64 machine can execute ARM64 binaries. On a native ARM64 runner the host already executes ARM64 instructions, so the step adds setup time and nothing else. Keep it only in jobs that still build a foreign architecture on the same machine.

Will my GitHub Actions cache carry over from x64 to ARM64?

No. Cache entries are keyed by a version hash that includes the runner OS compression tool and the cached paths, so an entry written on warp-ubuntu-latest-x64-8x does not restore on warp-ubuntu-latest-arm64-8x. Expect one cold run per cache key after the switch, and put the architecture in your cache key so the two fleets stop competing.

Which Ubuntu ARM64 images does WarpBuild offer, and do file paths differ from GitHub-hosted runners?

Ubuntu 24.04 and Ubuntu 26.04 ARM64 images, in 2, 4, 8, 16, and 32 vCPU sizes. The Ubuntu 24.04 ARM64 image sets the work dir to /runner/_work rather than GitHub's /home/runner/work/, so any script with a hardcoded runner path must read $GITHUB_WORKSPACE instead.

Can I keep publishing multi-platform images while building each architecture natively?

Yes. Run one build job per architecture on its own native runner, push per-architecture digests, then join them with a manifest in a final job. The multi-platform guide covers the digest and manifest wiring end to end. If you are still deciding whether the platform supports the move at all, can GitHub Actions run on ARM64 answers that in one page.

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.