Productivity

GitHub Actions Self-Hosted Runner Raspberry Pi 5 Docker Build ARM64 Cache Miss Fix

GitHub Actions Self-Hosted Runner Raspberry Pi 5 Docker Build ARM64 Cache Miss Fix

Build pipelines on ARM64 hardware are fast — until they aren’t. A Raspberry Pi 5 running a GitHub Actions self-hosted runner should fly through Docker builds. Instead, many teams hit a wall: cache misses on every single run, rebuilding layers from scratch, watching 8-minute builds that should be 90 seconds.

The cause is almost always the same. The fix is surprisingly straightforward once you understand what’s actually happening with ARM64 layer caching in Docker’s BuildKit engine.

Key Takeaways

  • Cache misses on ARM64 self-hosted runners are primarily caused by BuildKit’s default --cache-from behavior ignoring the local layer store when no explicit registry cache is configured.
  • A Raspberry Pi 5 (BCM2712, 4× Cortex-A76 @ 2.4GHz) can cut Docker ARM64 build times from 8+ minutes to under 90 seconds once layer caching is correctly wired up.
  • GitHub’s hosted runners don’t offer native ARM64 as of May 2026, making the Raspberry Pi 5 one of the cheapest paths to native linux/arm64 CI — at roughly $80 hardware cost versus $0.008/minute for emulated builds on ubuntu-latest.
  • The three-part fix involves: setting a persistent DOCKER_BUILDKIT=1 environment, mounting a named cache volume, and pushing cache to a local registry between runs.

1. Why This Setup Exists in 2026

GitHub’s hosted runners still don’t ship with native ARM64 compute as of May 2026. The ubuntu-latest runner uses x86_64, which means any linux/arm64 Docker build runs through QEMU emulation — and QEMU on GitHub’s infrastructure is slow. Painfully slow. A multi-stage Node.js build that takes 55 seconds natively can hit 14 minutes under emulation, according to build timing data shared in r/github’s self-hosted runner thread.

The Raspberry Pi 5, released in late 2023 and now widely available, changed the calculus. At $80 for the 8GB model, it delivers a BCM2712 SoC with four Cortex-A76 cores at 2.4GHz and genuine linux/arm64 execution. No emulation. No QEMU overhead.

Teams building for ARM targets — embedded Linux, AWS Graviton deployments, Apple Silicon Docker containers — started moving CI to Pi 5 hardware. Dev.to’s writeup on self-hosting GitHub Actions via Cloudflare Tunnel documented exactly this pattern: a Pi sitting at home, exposed securely via Cloudflare, running the actions/runner container, cutting costs to near zero.

The problem nobody warned about: the moment you move to a self-hosted ARM64 runner, Docker’s cache behavior changes. The cache that worked perfectly on hosted runners breaks. Every build starts cold.

This matters because a cold ARM64 Docker build on Pi 5 takes roughly 5–8 minutes depending on layer complexity. A warm cached build takes 45–90 seconds. That’s a 5–6x difference — which matches the build time improvement cited in the r/github community thread about self-hosted runner tuning.


2. What’s Actually Breaking the Cache

The Root Cause: BuildKit’s Cache Resolution

BuildKit doesn’t automatically use the local layer cache the same way the classic Docker builder does. When you run docker build inside a GitHub Actions workflow without explicit cache directives, BuildKit looks for --cache-from sources. On hosted runners, GitHub provides an ephemeral cache layer. On self-hosted runners, it finds nothing and rebuilds from scratch.

The ACTIONS_CACHE_URL and ACTIONS_RUNTIME_TOKEN environment variables — which GitHub’s hosted runners inject automatically — aren’t always present on self-hosted runners, depending on how the runner was registered. Without these, the actions/cache action can fail silently, and BuildKit’s GitHub Actions cache backend (type=gha) falls back to a no-op.

Check this first. Run env | grep ACTIONS inside a workflow step. If ACTIONS_CACHE_URL is missing, that’s your culprit.

Why ARM64 Makes This Worse

Layer cache keys include the platform architecture. An amd64 cache entry won’t satisfy an arm64 build. This means even if you had working cache from a previous run on a different architecture, it’s useless. The cache miss problem is compounded because any cache warm-up done on x86 runners is completely invisible to the Pi.

The Three-Part Fix

Part 1 — Use a local registry as cache backend. Run a local Docker registry container on the Pi itself:

docker run -d -p 5000:5000 --restart always \
  -v /mnt/cache-registry:/var/lib/registry \
  --name local-registry registry:2

Mount it to an SSD or USB drive, not the SD card. Pi 5 supports USB 3.0 at ~400MB/s, which is adequate for layer storage.

Part 2 — Wire BuildKit to the local registry. In your workflow file:

- name: Build and push with cache
  run: |
    docker buildx build \
      --cache-from type=registry,ref=localhost:5000/myapp:buildcache \
      --cache-to type=registry,ref=localhost:5000/myapp:buildcache,mode=max \
      --platform linux/arm64 \
      -t myapp:latest .

The mode=max flag tells BuildKit to cache all intermediate layers, not just the final stage. This is the difference between 40% cache hits and 90%+ cache hits on multi-stage builds.

Part 3 — Persist the buildx builder. Don’t let the runner create a new builder instance per job. Create a named builder once on the Pi:

docker buildx create --name pi-builder --use --driver docker-container

Then reference it in workflows: docker buildx --builder pi-builder. Without this, each job spins up a fresh builder with no layer memory.


3. Comparing Cache Strategies on ARM64

StrategyCache Hit RateSetup ComplexityCostBest For
No cache (default)~0%None$0Never
type=gha (GitHub cache backend)40–60%Low$0 (5GB free)Occasional builds
Local registry (localhost:5000)80–95%Medium$0 (local disk)Frequent builds on Pi
External registry (GHCR/ECR)70–85%Medium-HighEgress costsMulti-runner setups
Inline cache (--cache-to inline)20–35%LowRegistry storageSimple single-stage builds

The local registry approach wins on hit rate because it has zero network latency and doesn’t compete with GitHub’s 5GB cache quota (which the gha backend shares with all other actions/cache calls in your repo). LeoTheLegion’s Docker self-hosted runner guide specifically calls out the builder persistence step as the most commonly missed configuration.

The tradeoff: a local registry is a single point of failure. If the Pi’s storage dies, the cache is gone. For most homelab or small-team setups, that’s acceptable. For production CI with multiple runners, replicate to GHCR.


4. Getting This Right in Practice

For small teams running one Pi:

Set up the local registry, mount it to USB SSD storage, and use the type=registry cache backend. Expect build times to drop from 6–8 minutes to 60–90 seconds after the cache warms up on the second or third run. The cache miss problem effectively disappears at this point.

For teams scaling to multiple Pi runners:

Push the build cache to GitHub Container Registry (GHCR) instead of localhost. GHCR is free for public repos and reasonably priced for private ones. Each runner reads from and writes to the same cache manifest, so any machine can warm the cache for any other. The latency hit compared to local registry is 2–4 seconds per layer pull, which is acceptable when the alternative is a full rebuild.

What to watch over the next 3–6 months:

GitHub has been expanding hosted runner options throughout 2025 and into 2026. If native ARM64 hosted runners ship at a competitive price point — current signals suggest $0.004–0.006/minute is the target range — the economic case for Pi-based self-hosted runners narrows significantly. Watch the GitHub Changelog for ubuntu-arm64-latest runner availability. That’s the signal that changes this calculation entirely.

Near-term: Docker’s BuildKit 0.14.x series (current as of May 2026) includes improved cache deduplication that reduces the registry storage footprint by roughly 30% for multi-stage ARM builds. Updating the docker/build-push-action to v6.x in your workflow pulls this in automatically.


5. The Bottom Line

The ARM64 cache miss fix isn’t a single command. It’s three things working together: a persistent named buildx builder, a local or remote registry as the cache backend with mode=max, and verified ACTIONS_CACHE_URL injection for the gha backend if you’re mixing strategies.

Get all three right and a Pi 5 at $80 delivers native ARM64 CI that’s faster than GitHub’s emulated runners and costs essentially nothing per-minute to run. That’s a hard combination to beat for small teams and homelabs.

Start here: Check env | grep ACTIONS in your next workflow run. If ACTIONS_CACHE_URL is absent, that’s your first problem — and fixing runner registration before touching BuildKit config will save you hours of debugging the wrong thing.


References: r/github self-hosted runner build time thread; Dev.to CI/CD on Raspberry Pi via Cloudflare Tunnel; LeoTheLegion Docker self-hosted runner guide (2025). Raspberry Pi 5 specifications from raspberrypi.com official product page.

References

  1. r/github on Reddit: We cut GitHub Actions build times by 6x with self-hosted runners — sharing our s
  2. Self-Hosting on Raspberry Pi: CI/CD with GitHub Actions and Secure Access via Cloudflare Tunnel - DE
  3. Use Docker to Set Up a Self-Hosted GitHub Actions Runner in 10 Minutes — LeoTheLegion

Photo by Rubaitul Azad on Unsplash