Productivity

GitHub Actions Self-Hosted Runner Pi 4 Docker Build Memory Fix

GitHub Actions Self-Hosted Runner Pi 4 Docker Build Memory Fix

Your Pi 4 has 4GB of RAM. Your Docker build needs 4GB of RAM. The math doesn’t work β€” and the OOM killer agrees.

If you’ve wired up a GitHub Actions self-hosted runner on a Raspberry Pi 4 to handle Docker builds, you’ve almost certainly hit the wall: the runner process inflates to several gigabytes of virtual memory just sitting idle, your build spawns containers on top of that, and the whole thing crashes mid-layer. No clean error. Just a dead job and a confused runner daemon.

This isn’t a niche edge case. As of early 2026, self-hosted runners on ARM single-board computers are increasingly common in homelab CI/CD pipelines, IoT edge deployments, and cost-conscious small teams who can’t justify GitHub’s hosted runner pricing for low-throughput workflows. The Pi 4 β€” still widely available in 4GB and 8GB configurations β€” looks attractive on paper: low power draw (~3–5W under load), cheap, ARM64 native for many modern Docker images. The problem is the runner’s memory profile doesn’t match the hardware’s constraints.

The fix isn’t one setting. It’s a combination of runner configuration, Docker daemon tuning, and build pipeline restructuring that together bring memory pressure down to manageable levels.

In brief: The GitHub Actions runner process has a known virtual memory inflation issue (tracked in runner issue #3796 on GitHub) that’s especially destructive on memory-constrained ARM hardware. Fixing the crash requires changes at three layers simultaneously.

  1. The runner’s idle virtual memory footprint can exceed 3GB even before a job starts, leaving almost no headroom for Docker build operations on a 4GB Pi.
  2. Docker’s default build configuration β€” specifically BuildKit’s parallelism and layer caching behavior β€” amplifies memory pressure significantly on ARM64.
  3. A combination of swap configuration, runner process limits, and BuildKit flags can reduce peak memory usage by 40–60% without materially increasing build times.

Background: Why This Keeps Happening

The GitHub Actions runner is a .NET application. On Linux ARM64 systems, .NET’s memory management behaves differently than on x86-64 β€” specifically, the runtime reserves large amounts of virtual address space speculatively. According to the open GitHub issue #3796 in the actions/runner repository (filed mid-2024 and still active as of March 2026), users reported the idle runner listener consuming upwards of 3–4GB of virtual memory while using only a fraction of that in resident set size (RSS).

Virtual memory inflation sounds harmless β€” it’s not actually allocated RAM, right? On a desktop with 32GB, that’s true. On a Pi 4 with 4GB and limited swap, the OS’s memory accounting still factors virtual reservations into commit limits. When Docker’s BuildKit engine tries to spin up build workers, the kernel’s overcommit behavior intersects badly with the runner’s footprint.

Docker BuildKit, which became the default build engine in Docker 23.0 (released early 2023), runs a buildkitd daemon that manages parallel build stages. On an x86 machine with 16GB of RAM, that parallelism is a net win. On a Pi 4, concurrent stage execution during a multi-stage Dockerfile build can spike memory demand by 1.5–2x compared to sequential builds, according to BuildKit’s own documentation on --max-parallelism.

The ARM64 angle matters too. Many Docker base images weren’t natively built for linux/arm64 until 2022–2023. Builds that use --platform linux/arm64 emulation via QEMU on an x86 host are slow but memory-stable. On the Pi itself building natively, you skip QEMU overhead β€” but you’re now doing real work on real constrained hardware.


The Idle Memory Problem: Runner Process Bloat

The root cause documented in issue #3796 is .NET’s VirtualAlloc behavior on Linux ARM64. The runner reserves address space in large chunks for managed heap expansion that, in practice, never fully materializes. RSS (what’s actually in RAM) is typically 200–400MB for an idle runner. But virtual memory reservations show 3–4GB in top or htop output.

This creates a false alarm in one sense β€” but a real problem in another. When docker build starts and the kernel needs to commit memory for BuildKit workers, the overcommit accounting looks at virtual reservations. On systems where /proc/sys/vm/overcommit_memory is set to 0 (heuristic, the default), the kernel can refuse allocations that look like they’d exceed physical limits, even if the runner’s virtual footprint is largely phantom.

The fix at this layer: Set DOTNET_GCHeapHardLimit as an environment variable for the runner service. This caps the .NET GC’s heap reservation. A value of 512M is a reasonable starting point:

# In /etc/systemd/system/actions.runner.<your-runner>.service
Environment="DOTNET_GCHeapHardLimit=536870912"

This doesn’t reduce RSS much, but it dramatically reduces virtual memory inflation β€” from 3–4GB down to roughly 600–900MB in practice, based on community reports in issue #3796.


Docker BuildKit on ARM64: Parallelism Is the Enemy

With runner memory stabilized, the next crash vector is BuildKit itself. By default, buildkitd uses as many concurrent workers as there are CPU cores. The Pi 4 has 4 cores. Four concurrent build workers during a multi-stage build can spike RAM usage by 800MB–1.2GB above baseline in large builds.

Two flags matter here:

# In /etc/docker/daemon.json
{
  "builder": {
    "gc": {
      "enabled": true,
      "defaultKeepStorage": "2GB"
    }
  }
}

And at the build invocation level:

docker buildx build \
  --build-arg BUILDKIT_INLINE_CACHE=1 \
  --memory="2g" \
  --memory-swap="3g" \
  .

The --memory flag caps the build container’s memory. Combined with a swap ceiling, this prevents OOM kills while giving the build enough headroom to complete. According to Docker’s BuildKit documentation, BUILDKIT_MAX_PARALLELISM can also be set as a build argument to limit concurrent stage execution β€” setting it to 1 or 2 on a Pi 4 is a meaningful trade-off.


Swap: The Unsexy Fix That Actually Works

Raspberry Pi OS ships with 100MB of swap by default. That’s almost deliberately useless. Expanding it is genuinely part of the fix β€” not a workaround.

Community consensus, corroborated by deployment guides for FastAPI and similar services on Pi hardware, points to 2–4GB of swap on a fast microSD or β€” better β€” a USB 3.0 SSD as the difference between a runner that occasionally crashes and one that reliably completes builds.

Setting up dphys-swapfile for 2GB:

sudo nano /etc/dphys-swapfile
# Set: CONF_SWAPSIZE=2048
sudo dphys-swapfile setup
sudo dphys-swapfile swapon

Swap on a USB 3.0 SSD writes at 300–400MB/s on the Pi 4, versus 20–40MB/s on a Class 10 microSD. That’s not fast enough to substitute for RAM in a performance context, but it’s plenty fast enough to prevent OOM kills during a Docker layer extraction or pip install step.

Where this approach can fail: microSD swap works but introduces a real card lifespan concern. Docker builds write heavily to temp directories, and sustained writes over months will cause card failure. A USB SSD is a $15–25 investment that removes this risk entirely. Treat microSD swap as temporary at best.


Memory Management Strategies: Side by Side

StrategyMemory ImpactBuild Time ImpactComplexityBest For
DOTNET_GCHeapHardLimit↓ ~2.5GB virtualNoneLowAll setups
Reduce BuildKit parallelism↓ 800MB–1.2GB peak+15–30% slowerLowMulti-stage builds
--memory flag on buildHard cap, prevents OOMMinimal if sized rightLowAny Docker build
2GB+ swap on SSDPrevents crash, no RAM savedMinor (swap latency)MediumPersistent runners
Full swap on microSDPrevents crash (unreliably)+20–40% on heavy buildsLowBudget setups only
Move runner to Pi 5 / 8GB Pi 4Eliminates constraintNoneHigh (hardware)High-throughput teams

The DOTNET_GCHeapHardLimit setting is the highest-leverage single change β€” it costs nothing in build time and takes five minutes to implement. Combine it with 2GB of swap on any storage faster than microSD and parallelism set to 2, and a 4GB Pi 4 can handle moderately complex Docker builds without crashing.


Scenario-Based Fixes

Scenario 1 β€” Simple single-stage Dockerfile (Node.js app, Python service)

The crash is almost certainly the runner’s idle virtual memory colliding with a moment of peak Docker memory demand. Apply DOTNET_GCHeapHardLimit=536870912 and add 1GB of swap. That’s likely sufficient. Build times won’t change.

Scenario 2 β€” Multi-stage Dockerfile with dependency installation steps

This is where BuildKit parallelism kills you. Set BUILDKIT_MAX_PARALLELISM=2 in your workflow file:

- name: Build Docker image
  env:
    BUILDKIT_MAX_PARALLELISM: 2
  run: docker build -t myapp:latest .

Pair this with the GC limit and 2GB swap on an SSD. Build times increase by roughly 20%, but the crash rate drops to near zero.

Scenario 3 β€” You need faster, more reliable builds long-term

A Pi 5 with 8GB RAM costs roughly $80 and eliminates this entire class of problem. The GitHub Actions self-hosted runner on ARM64 works well when memory isn’t the bottleneck. If your build pipeline is genuinely important to your workflow, the hardware upgrade pays for itself in eliminated debugging time within weeks.

What to watch: The actions/runner team has been intermittently engaging with issue #3796 through early 2026. A proper fix at the .NET runtime configuration level β€” possibly shipping with runner versions beyond v2.315.x β€” could reduce or eliminate the need for manual DOTNET_GCHeapHardLimit tuning. Watch the runner release notes on the actions/runner GitHub repository.


Conclusion

This crash is genuinely solvable with configuration changes alone. But the solution is layered β€” no single setting fixes it.

  • DOTNET_GCHeapHardLimit cuts virtual memory inflation by ~2.5GB with zero build time cost
  • BuildKit parallelism limits reduce peak RAM demand by 800MB–1.2GB during multi-stage builds
  • 2GB+ swap on USB SSD provides the safety net that prevents OOM kills when the other two aren’t enough
  • microSD swap works short-term but accelerates card failure β€” don’t treat it as permanent

Looking 6–12 months out: runner v3.x is expected to move to a newer .NET runtime with better ARM64 memory behavior. Docker BuildKit is also adding more granular resource controls in upcoming releases. Both reduce the manual tuning burden over time. The open question worth tracking is whether GitHub adds official memory limit configuration to the runner’s startup flags, which would eliminate the need for systemd-level environment hacks entirely.

The action right now: apply the GC heap limit today. Five minutes. Immediate impact on the biggest single cause of the crash. Everything else is refinement from there.

Key Takeaways

  • The GitHub Actions runner’s .NET runtime inflates virtual memory to 3–4GB on ARM64, leaving almost no headroom on a 4GB Pi 4 β€” even before a build starts
  • Setting DOTNET_GCHeapHardLimit=536870912 in the runner’s systemd service file reduces virtual memory from 3–4GB to ~600–900MB with no build time penalty
  • Docker BuildKit’s default parallelism (one worker per CPU core) spikes RAM by 800MB–1.2GB on multi-stage builds β€” capping BUILDKIT_MAX_PARALLELISM to 2 is a direct fix
  • Raspberry Pi OS ships with 100MB swap by default; 2GB+ on a USB SSD is the safety net that prevents OOM kills when memory pressure peaks
  • microSD swap works but degrades the card over time β€” use USB SSD storage for any persistent runner setup
  • A Pi 5 with 8GB RAM (~$80) eliminates this problem class entirely for teams where build reliability is non-negotiable

References

  1. Self-Hosted Github actions runner listener uses huge amount of virtual memory when idle Β· Issue #379
  2. Deploying FastAPI on Raspberry Pi using GitHub Actions (Self‑Hosted Runner) | by Kumar Shishir | Dec

Photo by Shantanu Kumar on Unsplash