Skip to content
Latchkey LogoLatchkey home

GitHub Actions ARM runners: what breaks when you switch

GitHub Actions ARM runners are a one-line change to runs-on and a cheaper minute at every Linux size, and the reason most teams have not made the change is a compatibility fear that is now concentrated in three specific places rather than spread across the ecosystem. This page is about finding those three in a week, on one job, before you move anything else.

Three ways an arm64 switch fails: no prebuilt module, a hardcoded x86_64 download, an amd64-only image
The three failure shapes, and which of them fails loudly rather than quietly. Runner labels and specifications from docs.github.com, read on 21 September 2026.

The labels are ubuntu-24.04-arm, ubuntu-22.04-arm and ubuntu-26.04-arm on Linux, plus windows-11-arm. In a private repository they give you 2 vCPU with 8 GB of memory and 14 GB of SSD, which is the same shape as the x64 standard runner, so nothing about the machine forces a change to how the job is written.

The rate is covered separately in arm64 runner pricing, which has the discount at every size and the break-even that goes with it. This page is only about what happens when the job runs.

Switch one job, not the pipeline

Run the same job on both architectures for a week before you decide anything. A matrix over the two labels costs one extra set of minutes and produces the only evidence that matters, which is whether your own dependencies build and your own tests pass, and it does it without a rollback plan because the x64 leg is still there.

Watch for two different outcomes. A job that fails is good news: the failure names the package, you fix it or you stop, and the whole question is settled in an afternoon. A job that passes but takes noticeably longer is the outcome that needs investigating, because the usual cause is emulation somewhere in the job rather than the architecture being slow.

Compare billed minutes rather than wall clock while you do it, since every job is rounded up to a whole minute and that is what you are actually charged for.

.github/workflows/ci.yml
# one week, both architectures, no rollback needed
jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        runner: [ubuntu-latest, ubuntu-24.04-arm]
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

The three things that actually break

First, native modules without an arm64 prebuilt binary. The package falls back to compiling from source, which needs a toolchain to be present and takes minutes instead of seconds, or it fails outright. This is loud and it names itself in the install log, so it is the easiest of the three to deal with.

Second, anything that downloads a release asset with the architecture written into the filename. A setup script fetching a tarball whose name contains x86_64 or amd64 will either 404 or, worse, download an x64 binary that cannot execute. Scripts should read uname -m and branch, and the value to expect on a Linux arm64 runner is aarch64 rather than arm64, which is the detail that breaks hand-written checks.

Third, container images with no arm64 variant. This is the quiet one. A single-architecture image either fails to pull or runs under emulation, and emulated execution is correct, so nothing in the log says anything is wrong. It is simply several times slower, which turns a cheaper minute into a more expensive job and produces the conclusion that ARM is slow.

What breaksHow it shows upThe check to run first
Native module, no arm64 prebuildInstall compiles from source or failsRead the install log for a build-from-source line
Downloaded binary with a fixed arch404 on the download, or exec format errorGrep your scripts for x86_64 and amd64
Container image, amd64 onlySilently slower, sometimes a pull failuredocker manifest inspect the image for a linux/arm64 entry
A vendored compiled artifactRuns on x64, refuses on arm64Check what is committed as a binary in the repository

Emulation is the trap, not the architecture

Modern ARM server cores are competitive with their x64 equivalents, and an interpreted or JIT-compiled test suite generally lands within noise of where it was. What does not land within noise is anything running through QEMU, which is an instruction-level translation and costs a multiple rather than a percentage.

The usual way emulation arrives is a multi-platform Docker build. Setting up QEMU and asking one builder for both linux/amd64 and linux/arm64 is correct and it is the slowest possible arrangement, because one of the two architectures is being emulated for the whole build. It is also the arrangement in most copy-pasted workflow snippets.

Build each architecture on a native runner of that architecture and merge the two into one manifest afterwards. That costs a second job and removes the emulation entirely, and it is the change that most often turns a disappointing ARM migration into a successful one.

.github/workflows/build.yml
# slow: one builder, one architecture emulated through QEMU
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    platforms: linux/amd64,linux/arm64

# fast: each architecture builds natively, then the manifest is merged
jobs:
  build:
    strategy:
      matrix:
        include:
          - runner: ubuntu-latest
            platform: linux/amd64
          - runner: ubuntu-24.04-arm
            platform: linux/arm64
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: docker/setup-buildx-action@v3
      - uses: docker/build-push-action@v6
        with:
          platforms: ${{ matrix.platform }}

Why this page has no benchmark of its own

A single arm64 against x64 timing is a statement about one workload. A compile-bound Rust build, a Node test suite waiting on a database, and a Python job that spends its life in a C extension will move in three different directions on the same pair of machines, and publishing one number would invite every reader to apply it to a workload it says nothing about.

There is a second reason, and it is the more important one. The dominant term in a real migration is not the architecture, it is whether anything in the job ended up emulated. A benchmark that did not hit emulation would flatter the switch, and one that did would condemn it, and neither would tell you which of those two your pipeline is.

So the honest instrument is the matrix above, run on your repository for a week. Where a measured comparison does exist in this corpus it is stated as one: choosing a runner size measures the same work at two sizes and says exactly what was run.

Where the saving actually lands

The discount is real and it is modest at the bottom of the ladder. GitHub prices standard Linux arm64 at $0.005 a minute against $0.006 for x64, read on 21 September 2026, which is about $10 a month on ten thousand minutes. On larger runners the gap widens to nearly 40 percent, which is where an arm64 migration pays for the week you spent on it.

Do not let a small discount buy a slow pipeline. A 16.7 percent saving is erased by a job that takes 20 percent longer, so if the arm64 leg of your matrix is slower than that, the switch costs money at standard size no matter how good it looks on the rate card. The arithmetic and the break-even for every size are on arm64 runner pricing.

One caveat we should state about ourselves: Latchkey sells Linux x64 managed runners and does not sell arm64, so nothing on our price list is an arm64 alternative. If arm64 is the requirement, GitHub's own runners and the vendors that publish an arm64 rate card are the market, and the runner alternatives roundup lists them.

Key takeaways

  • Run both labels in a matrix for a week: a failure names the package, and a slowdown usually names emulation.
  • On a Linux arm64 runner uname -m returns aarch64, which is what hand-written architecture checks get wrong.
  • A single-architecture container image is the quiet failure: it runs emulated and nothing in the log complains.
  • Build each architecture on its own native runner and merge the manifest rather than emulating one through QEMU.

Frequently asked questions

Which ARM runner labels does GitHub Actions provide?
On Linux, ubuntu-24.04-arm, ubuntu-22.04-arm and ubuntu-26.04-arm; on Windows, windows-11-arm and windows-11-vs2026-arm. In private repositories they are 2 vCPU with 8 GB of memory and 14 GB of SSD, matching the standard x64 runner, and in public repositories the standard runners are 4 vCPU with 16 GB.
Will my build work on ARM without changes?
For most interpreted and JIT-compiled languages, yes. The risk is concentrated in native modules with no arm64 prebuild, scripts that download a binary with the architecture in its filename, and container images published for amd64 only. Run one job on both labels for a week and all three surface immediately.
Is ARM slower than x64 in CI?
Not inherently. Almost every report of ARM being dramatically slower turns out to be emulation: an amd64 container image or a QEMU multi-platform build running instruction translation for the whole job. Check for a linux/arm64 entry in every image you pull before concluding anything about the architecture.
How do I build images for both architectures without emulation?
Build each platform on a native runner of that architecture, one job on ubuntu-latest and one on ubuntu-24.04-arm, then merge the two into a single multi-platform tag with a docker buildx imagetools create step once both have finished. Asking one builder for both platforms through QEMU is correct and several times slower.

Related guides

References

Chasing 16.7 percent on arm64? Latchkey is $0.0025 against $0.006 on x64, with nothing to port. Start free → 30-day trial · No credit card