runnerd
runnerd runs ephemeral GitHub Actions runners on a Linux host: one fresh container per job, from one pool shared by the repositories you configure, kept inside a memory and CPU budget.
It is in early development: the daemon schedules runners, records every job, can keep a failed job's runner to open a shell in, advises on each profile's memory and slots from past jobs' peaks, can admit runners by those peaks instead of their caps, serves a JSON API and a web dashboard, and runnerctl reads and controls it from a terminal.
flowchart TB
github["GitHub<br/>queued jobs, JIT runners, steps"]
subgraph daemon["runnerd serve"]
engine["engine"] --- history[("job history<br/>SQLite")]
api["API: JSON and events<br/>on a Unix socket"]
web["dashboard<br/>127.0.0.1:7477"]
end
subgraph slice["runnerd.slice: MemoryMax is the budget"]
jvm1["jvm 1"]
jvm2["jvm 2"]
node1["node 1"]
mirror["Docker Hub mirror"]
end
github <-->|"HTTPS, the gh CLI's token"| engine
engine -->|"Docker API: one container per job"| slice
runnerctl["runnerctl, runnerctl tui"] --> api
browser["browser"] --> web
web --> api
api --> engine
First run
On a Linux host with Docker:
|
Serve only private repositories: on a public one, anyone who opens a pull request can run code on this host (Configuration).
runnerd init writes two files and changes nothing else:
~/.config/runnerd/runnerd.toml: the repositories, and one profile with slots of up to 8G, one per 4 CPUs, inside three quarters of the host's memory (or the slice'sMemoryMaxif that is smaller), sorunnerd check-configpasses;~/.config/systemd/user/runnerd.service: a user unit runningrunnerd servewith that config.
It then runs runnerd doctor and prints what is left as commands. It prints the ones that need root and never runs
them; on a fresh host they are usually the slice, sized for this host, and Sysbox. Run the commands your init
printed, not a copy from elsewhere.
Then start the service. The config's profile runs this release's published image,
ghcr.io/ayagmar/runnerd-runner:base-<version>, which runnerd pulls before the first runner. Published images are
linux/amd64 only today, so on aarch64 runners do not start yet.
init never replaces a file unless given --force. --slice names another slice, --config writes the config
elsewhere.
Install
The installer downloads runnerd and runnerctl for Linux x86_64 or aarch64, checks their SHA-256, copies them to
~/.local/bin and runs runnerd doctor. It never uses sudo. To pick the directory or the release:
|
For v0.0.3 and earlier, run that release's own install.sh. Every release archive, install.sh and sha256.sum has
a GitHub build provenance attestation:
From crates.io the daemon is runnerd and runnerctl is in runnerd-cli. cargo install needs Rust 1.99 or later;
cargo binstall downloads the release archives instead:
Requirements
- Linux with cgroup v2, and Docker using the systemd cgroup driver.
- Sysbox, so each runner container can run its own Docker Engine without being privileged.
- A systemd slice whose
MemoryMaxis the budget for all runners;runnerd initprints one sized for the host. - The GitHub CLI, logged in with the
reposcope.
runnerd doctor checks each of these and prints the fix for what is missing.
Documentation
The site, https://ayagmar.github.io/runnerd/, has the guides:
- Install: requirements,
runnerd init's output, verifying a release. - Configuration:
runnerd.toml, profiles, runner images, records of failed jobs, sizing advice from the job history, measured admission, the budget check, isolation. - Running: the service unit and
systemctl, how runners are scheduled and caches kept warm, records of failed jobs and their shells, the job history, and the JSON API and its events. - Dashboard and CLI: the web dashboard, every
runnerctlcommand, andrunnerctl tuiwith its keys.
ARCHITECTURE.md says where the code lives and docs/adr why.
Building
The toolchain is pinned in rust-toolchain.toml. Before sending a change, run the checks listed under "The gate" in
AGENTS.md; CI runs the same. CONTRIBUTING.md explains which CI jobs a change
triggers and how a release is cut.
The tests that start real containers are ignored by default. They need Docker with sysbox and the images:
License
MIT