GitHub Actions on ARM64#

ARM64 is no longer optional infrastructure. AWS Graviton instances, Apple Silicon developer machines, and Ampere cloud hosts all run ARM64 natively. If your CI pipeline only builds and tests on x86, you are shipping untested binaries to a growing share of your deployment targets.

GitHub-Hosted ARM64 Runners#

GitHub offers native ARM64 runners. For public repositories, these have been available since late 2024. Private repositories gained access in 2025. Use them with:

jobs:
  build-arm64:
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: make build

This runs your entire job on native ARM64 hardware. No emulation, no cross-compilation, no surprises. The runner image is Ubuntu 24.04 with the standard GitHub Actions toolchain preinstalled.

Self-Hosted ARM64 Runners#

For private repos without access to GitHub-hosted ARM64 runners, or when you need specific hardware, run your own:

  • Apple Silicon (Mac Mini M4, Mac Studio): install the GitHub Actions runner natively. Containers run ARM64 through Docker Desktop.
  • AWS Graviton (c7g, m7g instances): the most cost-effective option for always-on runners. Up to 40% cheaper than equivalent x86 instances.
  • Ampere Altra (Oracle Cloud, Hetzner): strong single-threaded performance, good for build workloads.

Label self-hosted runners so workflows can target them:

jobs:
  build:
    runs-on: [self-hosted, linux, arm64]

Cross-Compilation from x86#

When native ARM64 runners are unavailable, cross-compile on x86. Language support varies significantly.

Go is the easiest. The compiler natively supports cross-architecture builds with no extra toolchains:

- name: Build for ARM64
  run: GOOS=linux GOARCH=arm64 go build -o myapp-arm64 ./cmd/myapp

Rust requires adding the target and a linker:

- name: Install ARM64 target
  run: |
    rustup target add aarch64-unknown-linux-gnu
    sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Build for ARM64
  run: cargo build --release --target aarch64-unknown-linux-gnu
  env:
    CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc

C/C++ requires a full cross-toolchain:

- name: Install cross toolchain
  run: sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
- name: Build
  run: CC=aarch64-linux-gnu-gcc CXX=aarch64-linux-gnu-g++ make

QEMU Emulation: Use With Caution#

The docker/setup-qemu-action enables running ARM64 containers on x86 runners through user-mode emulation:

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    platforms: linux/amd64,linux/arm64
    push: true
    tags: myapp:latest

This works for many workloads, but QEMU user-mode emulation cannot reliably run Go binaries. The failure is a crash in lfstack.push, deep in Go’s runtime lock-free stack implementation. This is not a bug you can fix – it is a fundamental gap in QEMU’s emulation of atomic operations. If your Dockerfile runs go build or go test under QEMU emulation, expect random crashes.

Prefer cross-compilation over QEMU emulation whenever possible. QEMU is 5-10x slower and unreliable for certain language runtimes.

Matrix Builds Across Architectures#

Run the same workflow on both x86 and ARM64 with a matrix strategy:

jobs:
  build:
    strategy:
      matrix:
        include:
          - runner: ubuntu-latest
            arch: amd64
          - runner: ubuntu-24.04-arm
            arch: arm64
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: make build ARCH=${{ matrix.arch }}
      - name: Test
        run: make test

This ensures tests run natively on each architecture. Bugs that only manifest on ARM64 (alignment issues, endianness assumptions, atomic operation differences) get caught before deployment.

Caching Across Architectures#

ARM64 and x86 produce different binaries. They must have separate caches. Include the architecture in your cache key:

- uses: actions/cache@v4
  with:
    path: |
      ~/.cache/go-build
      ~/go/pkg/mod
    key: ${{ runner.os }}-${{ runner.arch }}-go-${{ hashFiles('**/go.sum') }}

Without ${{ runner.arch }} in the key, a cache hit from x86 will feed invalid binaries to an ARM64 build, or vice versa.

Docker Buildx for Multi-Arch Images#

Build and push images for both architectures in a single step:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    platforms: linux/amd64,linux/arm64
    push: true
    tags: ghcr.io/myorg/myapp:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

If your Dockerfile uses cross-compilation (see the multi-arch container builds guide), this completes quickly even on x86 runners. If it relies on QEMU emulation for the ARM64 build, expect it to take 5-10x longer than the x86 build.

Minimum Viable ARM64 Validation#

If you cannot run ARM64 in CI at all, at minimum cross-compile and verify the binary format:

- name: Cross-compile for ARM64
  run: GOOS=linux GOARCH=arm64 go build -o myapp-arm64 .
- name: Verify binary architecture
  run: |
    file myapp-arm64
    # Expected: ELF 64-bit LSB executable, ARM aarch64
    file myapp-arm64 | grep -q "ARM aarch64" || exit 1

This does not validate runtime behavior, but it catches build failures and ensures the binary is actually ARM64.

Cost Considerations#

ARM64 runners are cheaper on most CI providers. GitHub-hosted ARM64 runners cost the same per-minute rate as x86 for public repos (free) and are priced competitively for private repos. AWS Graviton self-hosted runners cost 20-40% less than equivalent x86 instances. The performance-per-dollar advantage makes ARM64 attractive even if you are not deploying to ARM64 targets.

Common Mistakes#

  1. Assuming x86 tests validate ARM64 behavior. Alignment requirements, atomic operation semantics, and floating-point edge cases differ between architectures.
  2. Using QEMU emulation for Go builds. Cross-compile instead. QEMU will crash Go’s runtime.
  3. Sharing caches between architectures. Always include architecture in cache keys.
  4. Not testing on native ARM64 when runners are available. Cross-compilation catches build errors but not runtime bugs.