Zero Cloud Minutes. Zero K8s Bloat. Zero Idle Waste.
An intelligent, autoscaling local GitHub Actions runner fleet with Dual-Engine execution (Docker Containers & Dedicated Linux VMs via OrbStack, Windows WSL2, and Multipass), with native Apple Silicon (arm64) & Intel/AMD (amd64), Verdaccio, Athens, apt-cacher-ng, and persistent multi-language caching.
- Why RunZero? (Competitive Landscape)
- Dual-Engine Execution (Containers vs. Full VMs)
- Hybrid Auto-Routing for Chrome / Lighthouse / Systemd
- Docker & VM Engine Compatibility
- Key Features
- Proxy Registries & Upstream Ecosystem
- Architecture Overview
- Dashboard Live Contract (State, Retention, Retry)
- Repository Structure
- Quick Start
- Automatic Cloud Fallback in Workflows
- Multi-Architecture Builds (ARM64 vs. AMD64)
- Makefile Commands Reference
- Configuration Reference (
.env) - Contributing
- License
| Feature / Metric | ⚡ RunZero | 🏢 Actions Runner Controller (ARC) | 🎭 nektos / act | 📦 Static Docker Runner |
|---|---|---|---|---|
| Target Environment | Local Mac / Windows / Linux | Enterprise Cloud K8s Fleet | Local CLI Scratchpad | Single Homelab Container |
| Idle RAM Overhead | ~20 MB (0 MB at idle) | 1.5 GB – 3.5 GB (Heavy K8s) | 0 MB (Manual CLI only) | ~500 MB+ (Always running) |
| Execution Engines | Dual-Engine (Docker + VM) | Containers Only (K8s Pods) | Containers Only | Container Only |
| Runs Real GitHub Queue | Yes (Pulls live jobs) | Yes | ❌ No (Local emulation only) | Yes |
| Personal Account Multi-Repo | Automatic Discovery | ❌ No (Org / Enterprise only) | ❌ N/A | ❌ No (1 repo per container) |
| Multi-Arch (Apple Silicon + x86_64) | Native ARM64 + Rosetta AMD64 | ❌ Fixed single arch | ||
| Built-in Package Proxies | APT + Verdaccio + Athens + Mirrors | ❌ None (Requires K8s PVC/NFS) | ❌ None | ❌ None |
| Setup Complexity | 1 Command (make start) |
❌ Complex Helm / CRDs | Simple CLI |
RunZero is the first local runner fleet that gives you the choice between ultra-lightweight Docker containers and dedicated Linux Virtual Machines:
| Engine Backend | Upstream Runtime | Best Used For | Verification Status |
|---|---|---|---|
🐳 Docker Containers (RUNNER_BACKEND=docker) |
Docker Engine / OrbStack | Fast unit tests, linting, build pipelines, JS/Node/Go steps (instant ~0.3s boot, ~20MB RAM). | ✅ Unit-tested + automated e2e in CI |
💻 OrbStack Linux Machines (RUNNER_BACKEND=orbstack-vm) |
OrbStack Virtualization | Full systemd support, background daemons, headless Chrome/Lighthouse, unconfined Docker daemon. | ✅ Unit-tested + manually verified against real OrbStack VMs |
🪟 Windows WSL2 (RUNNER_BACKEND=wsl2) |
Windows Subsystem for Linux 2 | Native Linux VM execution on Windows 10/11 & Windows Server. | |
🐧 Canonical Multipass (RUNNER_BACKEND=multipass) |
Canonical Multipass | Universal cross-platform VM backend for macOS, Linux, and Windows. |
The WSL2 and Multipass drivers register and run ephemeral runners and are covered by unit tests that verify command
construction (every subprocess call to wsl.exe / multipass is mocked at the call site), but
neither has been exercised against real hardware — this project has been developed and driven
entirely from a macOS host with OrbStack. See E2E_TESTING.md and
issue #12 for the full honest breakdown and how to
help verify one of these backends for real.
You don't have to choose just one! With RUNNER_BACKEND=auto and AUTO_ROUTE_VM=true, RunZero automatically inspects queued jobs and routes them intelligently:
# 1. Standard build job -> Automatically runs in ultra-fast Docker container (~0.3s)
jobs:
unit-tests:
runs-on: [ self-hosted, local ]
steps:
- run: npm test
# 2. Browser / Lighthouse / Systemd job -> Automatically routed to dedicated Linux VM!
jobs:
e2e-lighthouse:
runs-on: [ self-hosted, local, browser ] # or 'vm', 'e2e', 'lighthouse', 'systemd'
steps:
- run: npx lhci autorunVM-routed jobs (E2E, Lighthouse, anything needing a real Docker daemon or systemd) run in an
ephemeral OrbStack VM cloned (orbctl clone) from a golden base image runzero-vm-base-<arch>:
- Golden image (
make build-vm-base, also run bymake start): Docker engine, the shared toolchain fromdocker/provision-toolchain.sh(Node via nvm, pnpm, .NET, Chrome, Playwright deps,libatomic1, ...) and the Actions runner in/home/runner/actions-runner. It is stamped with the runner version and a hash of the provisioning script, so editing the script marks it stale and the next build refreshes it. Golden images are templates: they are never listed as runners and never pruned. - Per job: the bridge clones the image, starts it, then registers an
--ephemeralrunner with the job's labels (local, arch). If registration fails the VM is deleted rather than left idle.localhostis made IPv4-only in the VM (GitHub-hosted behaviour) so services bound to127.0.0.1work. VM names are sanitised to OrbStack's hostname rules; the repo mapping is kept in the bridge's persisted instance store (~/.local-github-runner/state/bridge-instances.json). - Rate limits: GitHub list calls use ETag revalidation (
304is free), calls stop while the quota is exhausted, and the completed-jobs history sweep runs in the background every 5 minutes so it can never delay job detection.
RunZero runs local caching proxy registries alongside the autoscaler so your runners and image builds never re-download packages over the public internet:
| Service / Dependency | Upstream Project | Local Port | Dashboard / Report | Description |
|---|---|---|---|---|
| APT Cacher NG | apt-cacher-ng | :49503 |
http://localhost:49503/acng-report.html | Caches Ubuntu/Debian .deb packages across Docker builds & VM golden image clones. |
| Verdaccio | Verdaccio | :49501 |
http://localhost:49501 | Private npm/yarn/pnpm caching proxy registry with web UI. |
| Athens | Athens Go Proxy | :49500 |
http://localhost:49500 |
Immutable Go module proxy and download cache. |
| Docker Registry Mirror | Docker Registry | :49502 |
http://localhost:49502 |
Pull-through mirror for Docker Hub images. |
| devpi | devpi-server | :49507 |
http://localhost:49507/root/pypi/+simple/ | Real pull-through caching proxy for pip/uv (PyPI) via its built-in root/pypi mirror index. |
| kellnr | kellnr | :49506 |
http://localhost:49506 |
Real pull-through caching proxy for Cargo/crates.io via kellnr's built-in crates.io proxy. |
| Node.js Toolchain | NVM & Node.js | Local | Pre-baked | Pre-installed Node.js 20 LTS, 22 LTS, and 24 Current with yarn & pnpm. |
| .NET SDK | Microsoft .NET 8 | Local | Pre-baked | Pre-installed .NET 8.0 SDK for C#/F# workflow pipelines. |
| Browser Testing | Playwright & Google Chrome | Local | Pre-baked | Pre-installed system dependencies and browser runtimes for E2E testing. |
Cargo has no single "index URL" env var the way pip/uv (PIP_INDEX_URL/UV_INDEX_URL) and Go (GOPROXY) do --
pointing it at kellnr requires a real ~/.cargo/config.toml source-replacement block (written automatically by
docker/start.sh for Docker-engine runners, and by OrbStackVMDriver for VM-engine runners); CARGO_SOURCE_*
env vars for a custom [source.*] table are silently ignored by Cargo. Likewise, pip refuses a plain-HTTP index
on any host other than localhost/127.0.0.1 unless that host is explicitly trusted via PIP_TRUSTED_HOST --
both runner engines set this automatically alongside PIP_INDEX_URL wherever the index isn't reached via
localhost; uv has no equivalent restriction.
The proxy registries above cache package downloads over the network. Package managers also keep a local,
already-extracted disk cache (~/.npm, ~/.cache/pip, ~/go/pkg, ~/.cargo/registry, etc.) so a second job
using the same package doesn't even need the network proxy -- this is what HOST_CACHE_DIR and the Cache
Analytics dashboard panel track. For the Docker engine this is a plain -v host:container bind mount. An
OrbStack VM is a real, separate guest filesystem with no such flag, but every non-isolated OrbStack VM (the kind
this project creates) automatically virtiofs-shares the entire host macOS filesystem into the guest at a fixed
path, /mnt/mac<absolute-macOS-path> -- confirmed live: a file written from inside a VM under
/mnt/mac/Users/... appears immediately, with matching ownership, at the real /Users/... path on the host,
and vice versa. OrbStackVMDriver uses this to mount --bind each host-side cache directory onto its
container-style destination path inside the VM before the job runs, so cache data written by one ephemeral VM
is really on host disk and visible to the next VM cloned for the same architecture. If a host cache directory
isn't visible via the mac share for some reason, the mount is skipped with a warning rather than failing the
job outright.
| Engine | macOS (Apple Silicon) | macOS (Intel) | Windows | Linux / Homelab |
|---|---|---|---|---|
| 🪐 OrbStack (Recommended for Mac) | ⭐ Containers + Linux VMs | ⭐ Fast | — | — |
| 🐳 Docker Desktop | Native Containers | Native Containers | Native Containers | Native Containers |
| 🪟 Windows WSL2 | — | — | ⭐ Native Linux VMs | — |
| 🐧 Native Linux Docker | Native (ARM64) | Native (AMD64) | — | ⭐ Native (Best for Servers) |
| 🚀 Canonical Multipass | Native VMs | Native VMs | Native VMs | Native VMs |
- Intelligent Dynamic Autoscaling:
- Watches your GitHub queue for unclaimed jobs.
- Spins up ephemeral instances (
--ephemeral) on-demand and tears them down immediately upon job completion (0 MB RAM consumed when idle).
- Owner-Wide Multi-Repo Auto-Discovery:
- Automatically monitors all active repositories under your personal GitHub account (
OWNER=your-username), explicit repo lists, or GitHub Organizations.
- Automatically monitors all active repositories under your personal GitHub account (
- Pluggable Multi-Backend Drivers:
- Easily switch between Docker containers, OrbStack VMs, Windows WSL2, and Canonical Multipass via
RUNNER_BACKEND.
- Easily switch between Docker containers, OrbStack VMs, Windows WSL2, and Canonical Multipass via
- Golden VM Base Cloning (
make build-vm-base):- Pre-bakes the unified toolchain into a golden base image (
runzero-vm-base-<arch>). VM jobs launch in seconds by cloning the base image.
- Pre-bakes the unified toolchain into a golden base image (
- Self-Healing Zombie Runner Reconciliation:
- Automatically detects offline runners that GitHub still marks as
busy: true(e.g. after abrupt host restarts), cancels orphaned jobs, and unblocks your queue.
- Automatically detects offline runners that GitHub still marks as
- Adaptive GitHub API Rate-Limiting:
- Intelligently filters to active repositories and dynamically paces API calls to keep you safely within GitHub's 5,000 req/hr ceiling.
- Built-in Local Proxy Registries & Caching:
- apt-cacher-ng (
:49503) for Debian/Ubuntu.debpackages. - Verdaccio (
:49501) for instant npm/yarn/pnpm caching + web dashboard. - Athens (
:49500) for immutable Go module caching. - Docker Registry Mirror (
:49502) for Docker Hub pull-through caching. - devpi (
:49507) for pip/uv PyPI pull-through caching. - kellnr (
:49506) for Cargo/crates.io pull-through caching. - Per-VM local disk caches (
~/.npm,~/.cache/pip,~/go/pkg,~/.cargo/registry, etc.) are real even for the OrbStack VM engine, bind-mounted from host storage via OrbStack's automatic/mnt/macfilesystem share -- see "OrbStack VM Local Disk Caching" below.
- apt-cacher-ng (
- Zero Cloud Bill:
- Self-hosted runners never consume GitHub Actions minutes (100% free and unlimited).
┌──────────────────────────────────────────────┐
│ RunZero Autoscaler Daemon │
│ (Queue Monitor, Rate Limiting, Discovery) │
└──────────────────────┬───────────────────────┘
│
Intelligent Auto-Router
(Inspects Job Labels & Platform)
│
┌─────────────────────────┴─────────────────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ Container Driver │ │ Pluggable VM Driver │
│ (runs-on: [self-hosted]) │ │ (runs-on: [..., vm, e2e]) │
└─────────────┬─────────────┘ └─────────────┬─────────────┘
│ │
┌─────────────┴─────────────┐ ┌─────────────┴─────────────┐
▼ ▼ ▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ ARM64 Run │ │ AMD64 Run │ │ OrbStack │ │ Windows │ │Multipass │
│ (Docker) │ │ (Rosetta) │ │ Machine │ │ WSL2/VM │ │(Cross-OS) │
└───────────┘ └───────────┘ └───────────┘ └───────────┘ └───────────┘
The dashboard is driven directly from the /api/fleet snapshot plus /api/cache and /api/logs
support endpoints.
- Fleet snapshot (
/api/fleet) includes:- Active runners, queue, and completed jobs
- Capacity (
busy_runners,free_slots,max_runners) - Repo priority / paused repos
- Rate-limit and Actions billing telemetry
- Completed job retention:
- Up to
100completed jobs are retained in memory (pkg/state/history.go) - Jobs are sorted newest-first by
completed_at
- Up to
- Failure diagnosis parity:
- Failed/timed-out/cancelled jobs include a human-readable
failure_reason failed_stepand top annotation messages are included where available
- Failed/timed-out/cancelled jobs include a human-readable
- Runner cleanup evidence:
- Before finished runners are pruned, the daemon retains the last runner log tail
- Up to
100runner log tails are retained, each capped at16 KiB
- Retry semantics:
- The UI calls
/api/actions/workflowwithrerunorrerun-failed - Retry actions mark history as dirty so the next autoscaler cycle bypasses the normal refresh throttle and updates completed-job history immediately
- The UI calls
- Placeholder/TODO audit (dashboard + backend shipped paths):
- Audit command:
rg "TODO|FIXME|stub|placeholder" web/src pkg --glob "**/*.{ts,tsx,go}" - Result: no unresolved TODO/FIXME/stub markers in shipped TypeScript/Go sources (remaining
placeholder=...matches are HTML input placeholder attributes)
- Audit command:
.
├── cmd/runzero/ # ⚙️ CLI entrypoints (daemon, bridge, doctor, VM base build)
├── pkg/ # 🧠 Go engine modules (api, daemon, driver, github, state, reaper, ...)
├── web/ # 🖥️ TypeScript/Vite dashboard (live fleet UI + API client)
│ ├── src/ # Dashboard TS, styles, generated OpenAPI types
│ ├── dist/ # Production bundle served by the Go daemon
│ └── package.json # Dashboard dependencies and scripts
├── docker/ # 🐳 Container Build Manifests & Entrypoints
│ ├── Dockerfile # Multi-arch runner image (ARM64 + AMD64)
│ ├── Dockerfile.autoscaler # Autoscaler daemon container
│ ├── Dockerfile.devpi # devpi pip/uv PyPI proxy image (no maintained multi-arch upstream)
│ ├── provision-toolchain.sh # Unified toolchain script (shared by Docker & VM base images)
│ └── start.sh # Runner entrypoint with proxy auto-detect
├── spec/ # 📐 TypeSpec API contract + generated OpenAPI artifacts
├── website/ # 🚀 Astro Static Website & Documentation
│ ├── src/ # Astro components, pages (Hero + Docs + Versions) & styles
│ ├── e2e/ # Playwright E2E tests (website + dashboard lifecycle flows)
│ ├── public/ # Self-hosted fonts, versions.json, and SVG assets
│ ├── astro.config.mjs # Astro static SSG configuration
│ └── package.json # Website dependencies
├── docs/ # 🌐 Compiled GitHub Pages Website
│ ├── index.html # Compiled Hero Landing Page
│ ├── docs/index.html # Compiled Dedicated Documentation Page
│ └── versions/index.html # Compiled Release Version Archive Page
├── docker-compose.yml # 🚀 Orchestration (apt-cacher + Verdaccio + Athens + Docker Mirror + devpi + kellnr)
├── Makefile # 🛠️ Unified management commands
├── .env.example # ⚙️ Configuration template
├── CONTRIBUTING.md # 🤝 Contributor guidelines & Git-Flow guide
├── LICENSE # 📄 MIT License
└── README.md # 📖 Main repository documentation
Warning
Self-hosted runners execute workflow code with near-root access to this machine. Only use RunZero for repositories whose contributors you trust, and never for public repositories that run fork pull requests. Read the threat model first.
make envEdit .env and configure:
ACCESS_TOKEN=ghp_yourPersonalAccessTokenHere
OWNER=your-username
AUTO_DISCOVER_REPOS=true
RUNNER_BACKEND=auto # 'auto', 'docker', 'orbstack-vm', 'wsl2', or 'multipass'
RUNNER_ARCH=both # 'both', 'amd64', or 'arm64'
PROXIES_ENABLED=true # Starts apt-cacher, Verdaccio, Athens & Docker Mirrormake build # Builds ARM64 + AMD64 + Autoscaler container images
make build-vm-base # Builds golden OrbStack VM base image for instant cloningmake start # Starts apt-cacher, Verdaccio, Athens, Docker mirror, and Autoscalermake status # View active autoscaler, proxies, and dynamic runner instances
make logs # Stream live autoscaler logsmake stop # (or make down)The dashboard has a dedicated GitHub Actions Minutes panel. It requires billing API access for the selected owner or org.
- Configure your scope in
.env:
OWNER=your-username
# ORG=your-org-name- Use
ACCESS_TOKENwith repository access and billing-read access for that target scope. - Rebuild/restart:
docker compose build autoscaler
make restart- Open dashboard:
http://localhost:49505and verify panel status saysBilling synced from GitHub.
Full setup guide: ACTIONS_BILLING_HOWTO.md
make check is the single quality gate, run identically by CI and the pre-push hook. It runs
go vet + go test -race -cover, the TypeSpec compile/type generation, the dashboard typecheck and
build, and bash -n + shellcheck on every maintained shell script.
| Command | Description |
|---|---|
make check |
Every quality gate above (what CI runs) |
make test |
Go unit tests only |
make lint |
go vet, website Oxlint and shellcheck |
make fmt / make fmt-check |
Format / verify formatting (Go + website) |
make e2e |
Playwright end-to-end tests for website + dashboard lifecycle/retry/cleanup flows |
See E2E_TESTING.md for what is automated in CI versus what requires a human
running a manual runbook locally (OrbStack VM, WSL2, Multipass).
The Makefile is split per sub-package under mk/ (go, spec, web, website, shell,
cache, stack, vm). Run make help for the full, grouped list. Highlights:
| Command | Description |
|---|---|
make install |
Bootstrap: install deps, build spec/UI/engine, install git hooks |
make env |
Run interactive .env configuration wizard |
make build |
Build the autoscaler and runner (arm64 + amd64) images |
make build-vm-base |
Build golden OrbStack VM base image for near-instant VM spins |
make start (or make up) |
Launch autoscaler, VM bridge, dashboard and caching proxies |
make stop (or make down) |
Stop the autoscaler, proxies and active runners |
make status (or make ps) |
Show autoscaler, proxies and active ephemeral runners |
make logs / make logs-all |
Stream autoscaler / all-service logs |
make dashboard |
Open the dashboard at http://localhost:49505 |
make check |
Run every quality gate |
make install-hooks |
Install pre-commit and pre-push guards into .git/hooks/ |
make website-dev / make website-build |
Astro docs dev server / build and sync to docs/ |
make cache-smoke / make cache-size / make info |
Validate proxies / cache disk usage / total disk usage |
make clean-caches / make clean-all |
Clear all caches / full reset |
make help |
Show all available Makefile commands |
| Variable | Description | Default |
|---|---|---|
ACCESS_TOKEN |
GitHub Personal Access Token (PAT) with repo scope |
Required |
OWNER |
GitHub username to auto-discover all owned repos | None |
REPOS |
Comma-separated list of target repos (owner/repo) |
None |
ORG |
Organization mode: runners register at org scope and serve queued jobs from the org's active repos | None |
RUNNER_BACKEND |
Execution driver (auto, docker, orbstack-vm, wsl2, multipass) |
auto |
AUTO_ROUTE_VM |
Automatically route browser/systemd/e2e jobs to VMs | true |
AUTO_DISCOVER_REPOS |
Automatically discover and monitor all user repos | true |
ACTIVE_REPO_DAYS |
Only monitor repos pushed in the last N days | 60 |
RUNNER_ARCH |
Runner architectures to spawn (arm64, amd64, or both) |
both |
PROXIES_ENABLED |
Enable apt-cacher, Verdaccio & Athens proxy registries for runners | true |
CACHE_ENABLED |
Enable persistent package/tool caching across runners | true |
MIN_RUNNERS |
Warm standby pool topped up after queued jobs are served (org scope in ORG mode, else round-robin across tracked repos; capped by MAX_RUNNERS; exempt from idle reaping) |
0 |
MAX_RUNNERS |
Maximum concurrent runner instances | 4 |
POLL_INTERVAL |
Queue check interval in seconds | 10 |
DISCOVERY_INTERVAL |
Auto-discovery refresh interval in seconds | 900 |
RATE_LIMIT_REFRESH_INTERVAL |
GitHub API quota refresh interval in seconds | 60 |
ACTIONS_BILLING_REFRESH_INTERVAL |
Actions minutes refresh interval in seconds | 300 |
GITLAB_URL |
Base URL for GitLab API (supports self-hosted instances like https://git.eckelmann.de) |
https://gitlab.com |
GITLAB_TOKEN |
GitLab Personal / Group Access Token with api scope |
None |
GITLAB_GROUP |
GitLab group to discover and monitor runners and queued jobs | None |
GITLAB_PROJECTS |
Comma-separated list of GitLab project paths to monitor | None |
PROVIDERS_CONFIG |
Optional path to a multi-instance providers YAML file | None |
Use this checklist before announcing your RunZero host to teammates or external users.
- Start stack and bridge.
make restart- Verify all cache proxies are reachable from both host and runner network.
make cache-smoke- Confirm runner image startup summary shows private endpoints (not public defaults).
make logsLook for lines showing Verdaccio/Athens/devpi/apt-cacher/kellnr connectivity.
- Ensure host Docker daemon uses the local Docker mirror for Docker-backend jobs.
Create or update /etc/docker/daemon.json:
{
"registry-mirrors": ["http://localhost:49502"],
"insecure-registries": ["localhost:49502"]
}Then restart Docker and rerun:
make cache-smoke- Validate real cache growth after one representative workflow run.
make cache-sizeYou should see non-zero growth in host cache and proxy volumes over repeated runs.
Contributions are what make the open-source community an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
Please see CONTRIBUTING.md for details on our code of conduct and the process for submitting pull requests.
Distributed under the MIT License. See LICENSE for more information.