GitHub Actions Self-Hosted Runner Docker ARM64 M2 Mac Memory Usage Optimization

Build pipelines on Apple Silicon have a dirty secret.
Run a GitHub Actions self-hosted runner inside Docker on an M2 Mac, leave it idle overnight, and wake up to a process consuming 3โ6 GB of virtual memory doing absolutely nothing.
This isn’t a new complaint. GitHub’s own issue tracker (#3796) has been accumulating reports since 2024, with engineers documenting idle runner processes consuming virtual memory far beyond what any reasonable background service needs. In 2026, with ARM64 becoming the default architecture for CI infrastructure โ and M2 Macs now common in developer-owned runner fleets โ the problem has real cost implications. Docker ARM64 environments surface this memory behavior more sharply than their x86 counterparts, partly due to how the .NET runtime handles virtual address space on Apple Silicon.
The thesis is straightforward: GitHub Actions self-hosted runner Docker ARM64 M2 Mac memory usage optimization isn’t optional tuning anymore. It’s a prerequisite for running a cost-efficient, stable CI fleet on Apple Silicon.
This analysis covers:
- Why the runner’s virtual memory footprint is architecturally different on ARM64
- How Docker’s resource constraints interact with the runner process on M2
- Configuration changes that measurably reduce memory consumption
- When self-hosted ARM64 runners beat managed alternatives โ and when they don’t
Key Takeaways GitHub Actions self-hosted runners on Docker ARM64 M2 Macs exhibit disproportionate virtual memory usage due to .NET runtime behavior on Apple Silicon, with idle processes regularly consuming 3โ6 GB VSZ. Applying container memory limits, runner environment tuning, and process supervision can reduce resident set size (RSS) by 40โ60% without affecting throughput.
- The virtual memory issue is documented in GitHub’s runner repository (issue #3796) and affects any .NET-based runner process on ARM64, not just Docker deployments.
- Docker’s default behavior on macOS uses a Linux VM layer (via Docker Desktop’s virtualization), which adds overhead that compounds the runner’s native memory profile.
- Managed runner alternatives like Namespace.so, Depot, and Actuated offer ARM64 capacity without the configuration burden, but at per-minute pricing that breaks down at scale.
Why ARM64 CI Became a Memory Problem
The shift toward Apple Silicon in CI started around 2023, accelerated through 2024โ2025 as GitHub’s own hosted runners added ARM64 options (ubuntu-22.04-arm became generally available in late 2024). Teams running macOS builds started co-locating self-hosted runners on their M2 development machines and CI nodes.
The runner itself is a .NET 8 application. On ARM64 Linux and macOS, the .NET runtime reserves large virtual address space upfront โ this is expected behavior for the CLR’s memory model, not a bug. But virtual memory on a shared machine isn’t free when Docker Desktop is involved. Docker on macOS runs containers inside a lightweight Linux VM with a fixed memory ceiling set in Docker Desktop preferences, defaulting to 8 GB on most installations. When the runner process inside that VM claims 3โ5 GB VSZ while idle, it’s competing directly with the VM’s actual working memory.
GitHub’s issue tracker (#3796) shows consistent reports: engineers measuring VSZ values of 4โ6 GB for idle Runner.Listener processes on ARM64, while RSS (actual physical memory) sits lower โ typically 200โ400 MB. The gap between VSZ and RSS is normal for .NET applications, but it causes confusion and, more practically, triggers container OOM situations when Docker enforces memory limits against virtual rather than resident usage.
The self-hosted runner ecosystem has also grown significantly. According to Digger’s 2026 analysis of top self-hosted runner solutions, teams running more than 500 CI minutes per day almost universally reach a cost breakpoint where self-hosted infrastructure beats managed runners โ but that calculus assumes the infrastructure itself isn’t burning resources inefficiently.
The .NET Runtime and ARM64 Virtual Memory Behavior
The Runner.Listener process reserves large virtual address ranges at startup. That’s the .NET garbage collector pre-allocating segments it might need. On x86-64 Linux, this behavior is similar, but the address space management differs enough that VSZ readings appear lower in practice.
On ARM64 macOS โ specifically M1/M2 chips running Docker Desktop โ the virtualization layer doesn’t compress virtual address space the same way a native Linux host would. A runner that sits idle for hours maintains its full VSZ reservation. This is confirmed in the GitHub runner issue #3796 thread, where multiple reporters show htop output with 4โ6 GB VSZ on processes with under 300 MB RSS.
The practical fallout: if Docker Desktop’s VM is capped at 8 GB and you’re running two or three runner containers simultaneously, you’re starting jobs with very little headroom even though the runners are technically “idle.”
Docker Resource Constraints: What Actually Helps
The standard advice โ “add --memory flags to your container” โ is partially correct but misses nuance.
Setting a hard memory limit (--memory=2g) without a corresponding --memory-swap value causes the Linux OOM killer to terminate the runner process when VSZ calculations trigger the limit, even if RSS is fine. The correct pairing:
# docker-compose.yml snippet
services:
runner:
image: myrunner:arm64
mem_limit: 4g
memswap_limit: 4g
environment:
- DOTNET_GCHeapHardLimit=1073741824 # 1 GB heap hard cap
- DOTNET_GCConservatoryModes=1
The DOTNET_GCHeapHardLimit environment variable directly caps the .NET GC heap, which is the primary driver of virtual address reservation. Setting it to 1 GB reduces VSZ on idle runners by approximately 40โ60% based on reports in the GitHub issue thread, bringing measurements from 4โ5 GB VSZ down to 1.8โ2.2 GB VSZ in practice.
DOTNET_GCConservatoryModes=1 (listed as DOTNET_GCConservative in some .NET 8 docs โ verify against your exact runtime version) tells the GC to release memory more aggressively between collections rather than holding segments speculatively.
This approach can fail when teams set DOTNET_GCHeapHardLimit too aggressively. Cap the heap below what your actual build workloads need and you’ll trigger GC pressure during job execution, slowing builds in ways that are difficult to diagnose. Start at 1 GB for idle baseline tuning, then profile under real job load before lowering further.
Process Supervision and Runner Lifecycle
Long-running idle runners accumulate memory over time even with GC tuning. The runner process doesn’t fully release certain internal buffers between job executions. Two patterns address this effectively.
Pattern 1 โ Ephemeral runners with --ephemeral flag. Each runner picks up one job and exits. The container orchestrator spins up a fresh container for the next job. This guarantees a clean memory state per job. The trade-off is cold-start time โ typically 8โ15 seconds for a pre-pulled Docker image on M2.
Pattern 2 โ Scheduled restarts. For teams not ready for full ephemeral runners, a cron job that restarts the runner container every 4โ6 hours reclaims accumulated memory. Blunt, but measurably effective.
Comparison: Self-Hosted ARM64 Approaches in 2026
| Factor | Docker on M2 Mac (Tuned) | Kubernetes ARM64 Node | Managed ARM64 (Depot/Actuated) |
|---|---|---|---|
| Setup complexity | Medium | High | Low |
| Idle memory cost | 1.8โ2.5 GB VSZ (tuned) | ~1.5 GB per pod | N/A (no persistent process) |
| Per-minute cost at 1,000 min/day | ~$0 (sunk hardware) | ~$0.01โ0.03 (cloud node) | ~$0.004โ0.008 per minute |
| Cold start time | 8โ15 sec | 10โ20 sec | 5โ12 sec |
| ARM64 native support | Yes (M2 native) | Varies by node | Yes |
| OOM risk without tuning | High | Medium | None |
| Best for | Small teams, owned hardware | Mid-scale, cloud-native | Burst workloads, no ops burden |
Managed options like Depot, Actuated, and Namespace eliminate the memory optimization problem entirely โ there’s no persistent runner process to leak memory. According to OneUptime’s February 2026 guide on self-hosted runner auto-scaling with Kubernetes, teams at 500+ concurrent jobs typically move toward Kubernetes-based orchestration, which offers better memory isolation than Docker Desktop on macOS.
For teams under that threshold, tuned Docker on M2 remains the most cost-efficient path. The hardware cost is already paid.
Three Scenarios Worth Knowing
Scenario 1 โ Small team, 2โ3 M2 Mac minis as dedicated CI nodes.
The core risk is Docker Desktop’s VM hitting its memory ceiling under concurrent runner load. Fix: increase the Docker Desktop VM allocation to 12โ16 GB, apply DOTNET_GCHeapHardLimit, and switch to ephemeral runners. Expected result: stable memory under 3 GB total across two runners, no OOM events.
Scenario 2 โ Developer machine doubling as a runner.
This is where memory contention hurts most. A developer running Xcode, a browser, and two runner containers on a 16 GB M2 MacBook Pro is operating with less than 4 GB free for actual work. Restrict the Docker Desktop VM to 4 GB, set --memory=3g on the runner container, and use scheduled restarts to prevent creep. This isn’t ideal โ it’s a compromise โ but it’s stable.
Scenario 3 โ Team scaling beyond 10 concurrent jobs.
At this point, Docker on M2 Macs hits structural limits regardless of tuning. Kubernetes with ARM64 nodes (AWS Graviton 3 or self-managed M2 clusters) provides better isolation and programmatic resource management. OneUptime’s 2026 Kubernetes scaling guide documents the setup pattern: actions-runner-controller with horizontal pod autoscaling based on pending workflow queue depth.
One thing worth tracking: GitHub has been gradually improving the runner’s memory behavior in its 2.x releases. The issue #3796 thread shows some improvement between runner v2.311 and v2.321 (late 2025), but no complete resolution. Checking runner release notes for GC-related changes is worth five minutes per release cycle.
Where This Leaves You
The GitHub Actions self-hosted runner Docker ARM64 M2 Mac memory usage story in 2026 comes down to three concrete realities.
VSZ inflation is architectural, not a bug. The .NET runtime on ARM64 reserves address space speculatively, and Docker Desktop’s virtualization layer amplifies the visible impact. Two environment variables โ DOTNET_GCHeapHardLimit and DOTNET_GCConservatoryModes โ plus ephemeral runner mode cut idle memory consumption by 40โ60%. Managed alternatives remove the problem entirely but shift costs from capital to operational, with the crossover point sitting around 500 CI minutes per day.
Over the next 6โ12 months, expect gradual improvement from GitHub’s runner team, potentially better Docker Desktop memory accounting for ARM64 VMs, and continued growth of managed ARM64 runner services. Apple Silicon’s performance advantages for native ARM64 workloads โ Swift, React Native, mobile CI โ mean this architecture isn’t going away.
The immediate action is specific: add DOTNET_GCHeapHardLimit to your container environment this week. It’s a one-line change with measurable impact. Everything else โ ephemeral runners, Kubernetes migration, managed services โ is a strategic decision that takes time to evaluate. That one variable is just leaving performance on the table.
What’s your current runner memory baseline on ARM64? That number tells you exactly how much headroom the tuning will recover.
References
- Self-Hosted Github actions runner listener uses huge amount of virtual memory when idle ยท Issue #379
- How to Set Up GitHub Actions Self-Hosted Runners with Auto-Scaling on Kubernetes
- Top self-hosted runner solutions for GitHub Actions. - OpenTaco
Photo by Roman Synkevych on Unsplash


