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, shares one build cache across every slot that only the default branch writes, can keep a failed job's runner to open a shell in, advises on each profile's memory and slots from past jobs' peaks and can apply that advice itself, can admit runners by those peaks instead of their caps and by CPU, says why each queued job waits, serves a JSON API and a web dashboard you can install as an app and reach through a proxy, 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.
Last, init prints the line that sends a workflow job to these runners:
runs-on:
A job runs here only when every label in its runs-on is in its repository's labels in runnerd.toml; jobs on
ubuntu-latest stay on GitHub's runners.
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. When it is missing,
runnerd doctorprints the install commands for Ubuntu, Debian, Arch, and the source build on Fedora, CentOS Stream, Rocky, Alma and Amazon Linux. Red Hat Enterprise Linux has Sysbox-EE only. - 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, runner images, records of failed jobs, the budget check, isolation, and every key with its default. - Admission and sizing: what a job's memory counts, sizing advice from the job history and auto sizing, measured and CPU admission,
runnerd simulate. - Running: the service unit and
systemctl, how runners are scheduled, warm runners that wait for the next job, how caches are kept warm, the shared build cache, records of failed jobs and their shells, the job history, and the JSON API and its events. - Dashboard and CLI: the web dashboard, from another device too, every
runnerctlcommand, andrunnerctl tuiwith its keys. - Upgrading: what each release changes on an existing host, and what to do before and after.
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:
The Gradle build cache test builds its own image from the Gradle image, so it needs the network too:
License
MIT