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 buildThis 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/myappRust 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-gccC/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++ makeQEMU 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:latestThis 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 testThis 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=maxIf 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 1This 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#
- Assuming x86 tests validate ARM64 behavior. Alignment requirements, atomic operation semantics, and floating-point edge cases differ between architectures.
- Using QEMU emulation for Go builds. Cross-compile instead. QEMU will crash Go’s runtime.
- Sharing caches between architectures. Always include architecture in cache keys.
- Not testing on native ARM64 when runners are available. Cross-compilation catches build errors but not runtime bugs.