ghr is a self-hosted GitHub Actions runner controller built on the official actions/scaleset Go SDK. It manages ephemeral runners via JIT configs, scale sets, and long-polling - targeting macOS (Apple Silicon and Intel).
Define runner groups with min/max scaling in a YAML config, and ghr handles binary downloads, runner registration, process lifecycle, health monitoring, and graceful shutdown. It integrates with macOS launchd for service management and supports Discord/webhook notifications and Uptime Kuma push monitoring.
- Scale Set orchestration - Runner groups with configurable min/max scaling via the official GitHub SDK
- Ephemeral JIT runners - Provisioned on-demand with just-in-time configs, cleaned up after each job
- macOS native - First-class
launchdintegration (ghr start/stop/restart/status) - YAML configuration - Single config file with environment variables for secrets
- Health monitoring - Detection of stuck runners, resource issues, and connectivity problems
- Notifications - Discord and webhook alerts for runner events
- Uptime Kuma - Push-based monitoring integration
- Structured logging -
slog-based with file rotation and per-runner log files - Host capacity budget - Optional memory/CPU budget shared by all groups, with group priorities
- Per-runner cgroups (Linux) - Optional memory and CPU limits so one runaway job cannot take the daemon or the host down
- Go 1.25+ (required by the
actions/scalesetSDK) - macOS (Apple Silicon or Intel)
- A GitHub organization or repository with self-hosted runner access
- A GitHub PAT or App credentials with runner management permissions
go build -o ghr ./cmd/ghr
Create a config.yaml:
github:
url: "https://github.com/my-org"
runner_group: "default"
runner:
version: "latest"
cache_dir: "/var/lib/ghr/cache"
workdir_base: "/var/lib/ghr/runners"
groups:
- name: "ci-runners"
max_runners: 10
min_runners: 2
labels: ["ci", "macos"]
- name: "deploy-runners"
max_runners: 2
labels: ["deploy", "macos"]
Authentication is handled via ghr login. Tokens are never stored in the config file - use environment variables or the credentials store.
# Authenticate with GitHub
ghr login
# Start as a launchd service (daemon)
ghr start --config config.yaml
# Run in foreground (debug mode)
ghr run --config config.yaml
# Check status
ghr status
# Restart after config changes
ghr restart
# Stop the daemon
ghr stop
# Emergency reset (kill all runners, delete this host's scale sets, clean workdirs)
ghr purge
ghr stop and ghr restart keep the scale sets on GitHub; only ghr purge deletes them. A label change on an existing scale set is therefore applied by ghr purge followed by ghr start.
Every group scales independently, so without a budget the sum of all groups can overcommit the host. Add an optional capacity block and give each group a reserve (what one runner of that group holds while it exists, idle or busy):
capacity: # optional; absent means groups are only bounded by max_runners
memory: 48G
cpus: 40 # may be fractional
groups:
- name: "heavy"
max_runners: 4
min_runners: 0
priority: 50 # optional, default 0, higher is served first
reserve: # optional, default zero
memory: 9G
cpus: 6
- name: "light"
max_runners: 10
reserve:
memory: 1G
cpus: 1
- A runner is provisioned only if its group's reserve fits in what is left of the budget, on both memory and CPUs. The reservation is taken before the JIT config is generated and released when the runner is torn down (job completed, idle kill, health kill, failed start, shutdown). Demand that does not fit is deferred, not dropped.
- Priority is strict: while a higher-priority group has deferred demand, no lower-priority group may take capacity. Groups with the same priority are served in the order their deferred demand started. A group with a zero
reserveis never blocked. min_runners * reserveof every group is protected: a group above its minimum cannot take capacity another group still needs for its own minimum. Validation rejects a configuration whose minimums do not fit incapacity.- When capacity is released, waiting groups are re-evaluated immediately in priority order instead of at their next poll.
- With a budget configured, idle runners above
max(min_runners, demand - busy)are torn down when queued jobs disappear, so they stop holding capacity. Without a budget nothing is torn down. - A dimension left out of
capacityis not limited.reserveandprioritywithoutcapacityare ignored with a warning. - Each group advertises to GitHub, as its scale set's maximum capacity, the runners it holds plus its share of the free budget, so GitHub stops assigning jobs to a host that has no room for them. The free budget is shared out in admission order, each runner counted once, always within
max_runners, the other groups' minimums and strict priority: groups waiting for capacity claim it first (priority, then waiting order); then every group that is not saturated takes one runner of headroom; then the rest is dealt one runner at a time to the saturated groups, and only what they cannot use to every group with a listener (priority, then group name). A group is saturated when the last assigned job count GitHub reported reaches the capacity it advertised, so more jobs may be queued behind that cap: a single loaded group on an idle host advertises the whole free budget but one runner per other group in one step. When no group is waiting, a group left without a share still advertises one more runner if it would be admitted on its own, so an idle group never starves another one of the last free runner; this can attract at most one job per group that then waits locally, and that wait switches the host back to strict sharing. The value is refreshed on every reservation, release, change of waiting demand or saturation and listener start or stop, and GitHub reads it at the next long poll; after each message a group handles, its own value is set before that poll starts. Warmmin_runnersrunners count as capacity they already hold, never as room for more. - Deferred demand is reported as an info-level "waiting for capacity" health issue, never as a degraded group.
ghr status(and the JSON status API) shows the budget total, used and free, and per group the reserved and waiting runner counts. Waiting state changes are logged once per change.
Sizes accept K, M, G, T and KiB, MiB, GiB, TiB (powers of 1024, like systemd and Docker) as well as KB, MB, GB, TB (powers of 1000).
Run one ghr per host with the same groups and give each host its own host_id:
host_id: "hel1" # optional; [A-Za-z0-9][A-Za-z0-9._]* (no dash), scale set names stay within 64 characters
groups:
- name: "kare-api" # scale set "kare-api-hel1", runner label "kare-api"
max_runners: 3
- With
host_idset, each group registers a scale set named<group>-<host_id>that carries the group name as a label, soruns-on: kare-apireaches every host. Each host keeps its own budget, and the advertised capacity routes jobs to the hosts that have room. - Every host needs its own
host_id: two hosts with the same one would share each group's scale set. A dash is rejected so that<group>-<host_id>can only come from one group on one host, and groups whose scale set names differ only by case are rejected. - Without
host_id, the scale set is named after the group, as before. Two hosts must never run the same group withouthost_id: they would share one scale set. - After a crash, startup kills the runners left behind and deregisters their registrations in the group's scale set. Only runners found in this host's own workdirs are deregistered, so another host's runners are never touched, even under a shared legacy name.
- Switching an existing host to
host_idis a one-time step: runghr purgewith the old config first, then sethost_idand start. A scale set left under the old name keeps the group label with no listener behind it, and jobs may still be routed to it.
groups:
- name: "heavy"
max_runners: 4
resources:
memory_max: 12G # memory.max
memory_high: 11G # optional, memory.high
cpu_weight: 100 # optional, cpu.weight (1..10000)
Each runner is started directly inside its own cgroup (clone3 with CLONE_INTO_CGROUP, Linux 5.7+) with memory.oom.group=1, so an out-of-memory kill takes down that runner's whole job tree and nothing else. When the runner is torn down, cgroup.kill removes any process a job left behind and the cgroup is deleted. Cgroups left by a crashed daemon are swept at startup.
When any group sets resources, ghr requires cgroup v2 and a delegated, writable cgroup, and refuses to start otherwise: it never silently skips enforcement. At startup it moves itself into a daemon leaf of its own cgroup (cgroup v2 forbids a cgroup that has controllers enabled for its children from holding processes itself) and enables the controllers it needs (memory always, cpu only when a group sets cpu_weight). ghr doctor verifies this against the running daemon. On other platforms resources is ignored with a warning, so the same config works on macOS.
Sample systemd unit:
[Unit]
Description=ghr GitHub Actions runner controller
After=network-online.target docker.service
Wants=network-online.target
[Service]
User=ghr
ExecStart=/usr/local/bin/ghr run --config /etc/ghr/config.yaml
Restart=on-failure
RestartSec=5
TimeoutStopSec=60
Delegate=yes
OOMPolicy=continue
[Install]
WantedBy=multi-user.target
- Keep
daemon.shutdown_timeout(default 30s) well belowTimeoutStopSec: on stop, ghr deregisters every runner from GitHub in parallel, an idle one before stopping it and a busy one once stopped, and returns when that timeout expires even if runners are still stopping or being cleaned up. A runner that ignores SIGTERM is killed at the timeout instead of after its 10s grace period, the runners still registered then are logged, and a workdir left half cleaned is removed at the next start. Delegate=yeshands the unit's cgroup subtree to ghr.- Keep the default
KillMode=control-group. Withprocessormixed, runner cgroups can survive a stop, and the next start then fails withEBUSYon cgroup v2's no-internal-processes rule. - On systemd 254 or newer,
DelegateSubgroup=daemonis an option: systemd then places the service in thedaemonleaf itself. - ghr refuses to start on kernels older than 5.7 when
resourcesis set (CLONE_INTO_CGROUPis required). OOMPolicy=continueis required: systemd's OOM policy reacts to OOM kills anywhere in the unit's subtree, so with the defaultstopa single runner hitting its limit would still stop the whole daemon and every job on the host.- Containers started by jobs (
container:andservices:) are created by the Docker daemon outside the runner cgroup and are not limited by it. Include their expected footprint in the group'sreserveinstead. cgroup.killneeds Linux 5.14+; on older kernels ghr falls back to killing the processes listed in the cgroup.
go test ./... # all tests
go test -race ./... # with race detector
go vet ./... # static analysis
golangci-lint run # lint (if installed)
ghr/
├── cmd/ghr/main.go # Entrypoint
├── internal/
│ ├── cli/ # Cobra commands (start/stop/run/status/purge/login/...)
│ ├── auth/ # Credentials, JWT signing, installation tokens, breaker
│ ├── config/ # YAML + env loading, validation, defaults
│ ├── controller/ # Scale-set orchestration and per-group scaler
│ ├── capacity/ # Host-wide memory/CPU budget with group priorities
│ ├── cgroup/ # Per-runner cgroup v2 limits (Linux)
│ ├── runner/ # Binary download (with SHA-256 verify) and process lifecycle
│ ├── github/ # scaleset SDK adapter
│ ├── health/ # Health monitor and check functions
│ ├── notification/ # Discord + webhook providers with filtering
│ ├── monitoring/ # Uptime Kuma push reporter
│ ├── api/ # Unix-socket JSON API exposing status/health
│ ├── launchd/ # macOS service install/uninstall via bootstrap/bootout
│ ├── state/ # Centralized daemon-state file paths (pid/sock/state)
│ ├── model/ # Shared data structs (no logic)
│ └── logging/ # Structured logging, rotation, tagged runner output
├── go.mod
└── go.sum
| Package | Purpose |
|---|---|
actions/scaleset |
Official GitHub Scale Set API + listener |
spf13/cobra |
CLI framework |
oklog/run |
Goroutine lifecycle management |
joho/godotenv |
.env file loading |
gopkg.in/yaml.v3 |
YAML config parsing |
log/slog (stdlib) |
Structured logging |
Proprietary. All rights reserved.
- GitHub: @RedBoardDev