Auto-scale GitHub Actions self-hosted runners as Docker containers or macOS VMs. Powered by actions/scaleset.
Runners are ephemeral — each container/VM handles exactly one job and is removed upon completion. No Kubernetes required.
flowchart LR
A["GitHub Actions<br/>(job queue)"] -- long poll --> B["runscaler<br/>(this tool)"]
B -- Docker API --> C["Runner Containers<br/>(ephemeral)"]
B -- Tart CLI --> D["macOS VMs<br/>(ephemeral)"]
- Registers a runner scale set with GitHub
- Long-polls for job assignments via the scaleset API
- Spins up Docker containers or macOS VMs with JIT (just-in-time) runner configs
- Removes containers/VMs automatically when jobs complete
- Cleans up all resources and the scale set on shutdown
- Zero Kubernetes — runs directly on any Docker host or Apple Silicon Mac
- Ephemeral runners — each job gets a fresh container/VM, no state leakage
- Auto-scaling — scales from 0 to N based on job demand via long-poll (no cron, no polling delay)
- Docker-in-Docker — optional DinD support for workflows that build containers
- macOS VMs via Tart — native Apple Virtualization.framework with APFS Copy-on-Write cloning
- VM warm pool — pre-boot macOS VMs for instant job pickup (~2s vs ~30s cold boot)
- Shared volumes — cross-runner caching via named Docker volumes
- Multi-org support — manage multiple scale sets from a single process, mix Docker and Tart backends
- Single binary — no runtime dependencies beyond Docker (or Tart for macOS)
- Config file or flags — TOML config with CLI flag overrides
-
Docker backend: Docker running on the host
-
Tart backend (macOS): Apple Silicon Mac with Tart installed:
brew install cirruslabs/cli/tart # Pull a macOS runner image (pre-installed with Xcode and runner dependencies) tart pull ghcr.io/cirruslabs/macos-tahoe-xcode:latestNote: Apple's Virtualization.framework limits each host to 2 concurrent macOS VMs. Set
max-runnersaccordingly. Each VM slot is assigned a deterministic MAC address to prevent DHCP lease exhaustion — no sudo required.The default VM resources from Cirrus Labs images:
Image CPU Memory Disk ghcr.io/cirruslabs/macos-tahoe-xcode:latest4 cores 8 GB (8192 MB) 120 GB ghcr.io/cirruslabs/macos-sequoia-xcode:latest4 cores 8 GB (8192 MB) 120 GB Override per VM with
cpuandmemoryunder[tart]in config. For iOS builds (Xcode), 8 GB+ is recommended. -
A GitHub Personal Access Token — required scopes depend on token type and runner level:
Token type Organization runners Repository runners Classic PAT admin:orgrepoFine-grained PAT Self-hosted runners: Read and write + Administration: Read Administration: Read and write Note: The token owner must be an org owner (for org runners) or have admin access to the repo (for repo runners). Fine-grained PATs targeting an organization may also require admin approval depending on org policy.
Shell script (Linux/macOS):
curl -fsSL https://raw.githubusercontent.com/ysya/runscaler/main/install.sh | shInstalls to ~/.local/bin by default (no sudo required). Set INSTALL_DIR to customize, or RUNSCALER_VERSION to pin a version:
# Install to a custom location (e.g. system-wide)
curl -fsSL https://raw.githubusercontent.com/ysya/runscaler/main/install.sh | INSTALL_DIR=/usr/local/bin sh
# Pin a specific version
curl -fsSL https://raw.githubusercontent.com/ysya/runscaler/main/install.sh | RUNSCALER_VERSION=v1.2.3 shGo install:
go install github.com/ysya/runscaler@latestBinary releases:
Download from Releases and add to your PATH.
# Generate config interactively
runscaler init
# Validate everything before starting
runscaler validate --config config.toml
# Start scaling
runscaler --config config.toml
# Or using CLI flags directly
runscaler \
--url https://github.com/your-org \
--name my-runners \
--token ghp_xxx \
--max-runners 10
# Dry run — validate config, Docker, and images without starting listeners
runscaler --dry-run --config config.tomlThen in your workflow:
jobs:
build:
runs-on: my-runners # matches --labels (defaults to --name if not set)
steps:
- uses: actions/checkout@v4
- run: echo "Running on auto-scaled runner!"| Command | Description |
|---|---|
runscaler |
Start the auto-scaler (default) |
runscaler init |
Generate a config file interactively |
runscaler validate |
Validate configuration and connectivity |
runscaler status |
Show current runner status via health endpoint |
runscaler doctor |
Diagnose and clean up orphaned containers/VMs |
runscaler version |
Show version, commit, build date, and runtime info |
runscaler update |
Update runscaler to the latest release |
runscaler update --check |
Check for updates without installing |
# Update to the latest release (downloads, verifies checksum, replaces binary in-place)
runscaler update
# Check if a newer version is available without installing
runscaler update --checkrunscaler update downloads the archive for your platform, verifies its SHA-256 checksum against the release's checksums.txt, then atomically replaces the running binary. Restart runscaler after updating.
If runscaler is killed unexpectedly (e.g. kill -9, crash, power loss), Docker containers or Tart VMs may be left behind. Use doctor to detect and clean them up:
# Check for orphaned resources
runscaler doctor
# Auto-remove orphaned containers, VMs, and volumes
runscaler doctor --fixThe --fix flag will refuse to run if runscaler is currently active (detected via health endpoint), preventing accidental removal of in-use resources.
Configuration can be provided via a TOML config file (--config) or CLI flags. When both are provided, CLI flags take priority over config file values.
Docker backend (default):
# config.toml
url = "https://github.com/your-org"
name = "my-runners"
token = "ghp_xxx"
max-runners = 10
min-runners = 0
labels = ["self-hosted", "linux"]
runner-image = "ghcr.io/actions/actions-runner:latest"
runner-group = "default"
log-level = "info"
log-format = "text"
[docker]
socket = "/var/run/docker.sock"
dind = true
shared-volume = "/shared"Tart backend (macOS):
# config.toml
backend = "tart"
url = "https://github.com/your-org"
name = "macos-runners"
token = "ghp_xxx"
max-runners = 2 # Apple limits 2 concurrent macOS VMs per host
runner-image = "ghcr.io/cirruslabs/macos-tahoe-xcode:latest"
labels = ["self-hosted", "macOS"]
log-level = "info"
[tart]
cpu = 4 # CPU cores per VM (0 = use image default)
memory = 8192 # Memory in MB per VM (0 = use image default)
runner-dir = "/Users/admin/actions-runner" # default
pool-size = 2 # pre-warm 2 VMs for instant job pickup (~2s vs ~30s cold boot)Avoid passing tokens as CLI flags (visible in ps output). Two alternatives:
Option 1: RUNSCALER_TOKEN environment variable — automatically used when no --token flag or config value is set:
export RUNSCALER_TOKEN=ghp_xxx
runscaler --url https://github.com/org --name my-runnersOption 2: env: syntax in config file — reference any environment variable by name:
token = "env:GITHUB_TOKEN" # reads from $GITHUB_TOKEN at startupPriority: --token flag > RUNSCALER_TOKEN env var > config file value (including env: resolution).
Multiple scale sets (mixed Docker + Tart):
# Global defaults (inherited by all scale sets)
runner-image = "ghcr.io/actions/actions-runner:latest"
runner-group = "default"
max-runners = 10
log-level = "info"
[docker]
socket = "/var/run/docker.sock"
dind = true
# Each [[scaleset]] runs independently.
# Inherits global settings if omitted.
[[scaleset]]
url = "https://github.com/your-org"
name = "linux-runners"
token = "ghp_aaa"
[[scaleset]]
backend = "tart"
url = "https://github.com/your-org"
name = "macos-runners"
token = "ghp_bbb"
max-runners = 2
runner-image = "ghcr.io/cirruslabs/macos-tahoe-xcode:latest"
labels = ["self-hosted", "macOS"]
[scaleset.tart]
pool-size = 2| Flag | TOML key | Default | Description |
|---|---|---|---|
--config |
Path to TOML config file | ||
--url |
url |
(required) | Registration URL (org or repo) |
--name |
name |
(required) | Scale set name (used as runs-on label) |
--token |
token |
(required) | GitHub Personal Access Token |
--backend |
backend |
docker |
Runner backend (docker or tart) |
--max-runners |
max-runners |
10 |
Maximum concurrent runners |
--min-runners |
min-runners |
0 |
Minimum runners to keep warm |
--labels |
labels |
<name> |
Runner labels (comma-separated) |
--runner-group |
runner-group |
default |
Runner group name |
--runner-image |
runner-image |
ghcr.io/actions/actions-runner:latest |
Runner image (Docker image or Tart VM image) |
--docker-socket |
[docker] socket |
/var/run/docker.sock |
Docker socket path (Docker backend) |
--dind |
[docker] dind |
true |
Mount Docker socket into runners (Docker backend) |
--shared-volume |
[docker] shared-volume |
Shared Docker volume path (Docker backend) | |
--tart-cpu |
[tart] cpu |
0 (image default) |
CPU cores per VM (Tart backend) |
--tart-memory |
[tart] memory |
0 (image default) |
Memory in MB per VM (Tart backend) |
--tart-runner-dir |
[tart] runner-dir |
/Users/admin/actions-runner |
Runner install directory inside Tart VM |
--tart-pool-size |
[tart] pool-size |
0 |
Number of pre-warmed VMs for instant job pickup |
--log-level |
log-level |
info |
Log level (debug/info/warn/error) |
--log-format |
log-format |
text |
Log format (text/json) |
--dry-run |
dry-run |
false |
Validate everything without starting listeners |
--health-port |
health-port |
8080 |
Health check HTTP port (0 to disable) |
[Unit]
Description=GitHub Actions Runner Auto-Scaler
After=docker.service
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/local/bin/runscaler --config /etc/runscaler/config.toml
Restart=on-failure
RestartSec=10s
[Install]
WantedBy=multi-user.target# Current platform
make build
# All platforms (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64)
make allBuilt on top of actions/scaleset, the official Go client library for GitHub Actions Runner Scale Sets.
Key components:
cmd/runscaler/ CLI entry point, commands (init, validate, status, doctor, version)
internal/
config/ Configuration management with Viper (flags + TOML)
backend/ RunnerBackend interface + Docker/Tart implementations
scaler/ Implements listener.Scaler for runner lifecycle
health/ Health check HTTP server
versioncheck/ GitHub releases API client for update notifications and in-place binary updates
The RunnerBackend interface abstracts container/VM lifecycle:
DockerBackend— manages runner containers via Docker APITartBackend— manages macOS VMs via Tart CLI (clone → run → exec → stop → delete)
The scaler implements three methods from the scaleset Scaler interface:
HandleDesiredRunnerCount— Scales up runners to match job demandHandleJobStarted— Marks runners as busyHandleJobCompleted— Removes finished runners
MIT