Configuration
One file, ~/.config/runnerd/runnerd.toml: the repositories to serve, the profile each one runs on, the shared caches, and the systemd slice whose MemoryMax is the budget. runnerd init writes a starter one. Every key, at the end, says what each key means and its default.
An example
Section titled “An example”Two repositories with two runners each, in a slice with MemoryMax=33G. examples/runnerd.toml in the repository is another complete one.
# 2 × 9G + 2 × 7G + 512M for the mirror = 32.5G.
[host]slice = "runnerd.slice"isolation = "sysbox"
[caches]mirror = { enabled = true, memory = "512M", cpus = 1, ttl = "168h", max_size = "10G" }pnpm = { max_size = "5G" }maven = { max_size = "5G" }# Slot 1's layers become a profile's snapshot once they changed this much and the profile is idle.reseed_at = "2G"# A reseed that would make a snapshot larger than this starts it over from slot 1's changes.snapshot_max = "10G"
[profiles.jvm]image = "ghcr.io/ayagmar/runnerd-runner:jvm-0.0.20"cpus = 8memory = "9G"slots = 2
[profiles.node]image = "ghcr.io/ayagmar/runnerd-runner:node-0.0.20"cpus = 8memory = "7G"slots = 2
[[repositories]]name = "halyard/ledger"profile = "jvm"labels = ["self-hosted", "linux", "x64", "jvm"]
[[repositories]]name = "halyard/storefront"profile = "node"labels = ["self-hosted", "linux", "x64", "node"]Split light and heavy jobs
Section titled “Split light and heavy jobs”A runner may take any queued job of its repository whose labels it has, so it is sized for the heaviest of them: under caps every job takes a slot at the profile’s memory, and under measured admission a runner reserves the largest prediction among its repository’s queued jobs. A 300M linter queued beside a 6G build waits like the build.
You see it in two places:
- The linter waits for room it does not need:
Needs 6.5G of memory; 3G of the budget is free, orAll 2 node slots are busy. - In
runnerctl sizing, its row has aP95peak far under the profile’smemoryand aWAIT P95as long as the build’s.
Serve the repository in a second, smaller profile: another [[repositories]] entry with the same name, its own profile and a label of its own. Added to the example above, under measured admission:
[host]slice = "runnerd.slice"isolation = "sysbox"admission = "measured" # the check holds the largest runner and the mirror, 9.5G, to MemoryMax
[profiles.small]image = "ghcr.io/ayagmar/runnerd-runner:node-0.0.20"cpus = 2memory = "1G"slots = 4
[[repositories]]name = "halyard/storefront"profile = "node"labels = ["self-hosted", "linux", "x64", "node"]
[[repositories]]name = "halyard/storefront"profile = "small"labels = ["storefront-small"]Then point the light jobs at it:
jobs: lint: runs-on: storefront-small # was [self-hosted, linux, x64]Under caps the check holds every slot at its cap to MemoryMax: the small profile’s 4 × 1G takes the example to 36.5G, past its 33G, so lower another profile’s slots or raise MemoryMax to make room.
A job runs in the pool that has every label in its runs-on. The small pool leaves out self-hosted on purpose: every job you did not move still asks for self-hosted, which only the node pool has, so a heavy job can never land on a 1G runner. Each pool runs in its own profile’s slots, reserves only for its own queued jobs, and has its own warm and build_cache. runnerctl status lists the repository once per pool, and the clients name a waiting job’s pool (halyard/storefront · small).
The config check refuses two pools of a repository that share a label (a job asking only for that label could run in either), two in the same profile, and pools whose build_cache_writers differ. A job no pool has every label for, such as one with a typo in runs-on, waits with No profile serves the label storefornt-small; one that asks for no labels waits with It asks for no labels, so no profile serves it. Only a job asking for exactly one GitHub-hosted label, such as ubuntu-latest or macos-15, is left to GitHub and not listed. Pause and resume apply to the whole repository; a pool whose runners keep failing holds back only itself.
Runner images
Section titled “Runner images”image/Dockerfile has four stages. Each release publishes every one, for linux/amd64.
| Stage | Adds |
|---|---|
base |
the GitHub Actions runner, Docker Engine with Compose and Buildx, the GitHub CLI, make, xz, zip, zstd, and runnerd’s scripts; every common toolchain’s cache in ~/.cache |
android |
the Android SDK command-line tools and platform-tools, and a JDK 17 |
jvm |
nothing yet over android |
node |
pipx and Playwright’s system libraries, over android |
Workflows install their language toolchains themselves (setup-java, setup-node, rust-toolchain), and the slot keeps them warm. A profile sets one of:
| Key | Runs | Pulled |
|---|---|---|
image = "ghcr.io/ayagmar/runnerd-runner:jvm-0.0.20" |
the published stage | before the profile’s first runner |
image_stage = "jvm" |
runnerd-runner:jvm, built on this host |
never |
runnerd logs the pull’s progress, and runnerd doctor says whether each image is present or can be pulled. Keep the tag at the daemon’s version: the image’s scripts and the daemon change together, and runnerd doctor warns when they differ. To run your own changes to image/, build the stage from a clone and set image_stage:
docker build --target jvm -t runnerd-runner:jvm imageAt start, after a reload and after a pull, runnerd removes the published images, ghcr.io/ayagmar/runnerd-runner, that no profile names and no container was made from, such as an older version’s (ADR 0040). It never removes an image built on this host or any other image; remove an old runnerd-runner:<stage> yourself.
Your own image
Section titled “Your own image”When your jobs need system packages the stages lack, build on base, or on android for Android builds:
FROM ghcr.io/ayagmar/runnerd-runner:base-0.0.20RUN apt-get update \ && apt-get install -y --no-install-recommends libpq-dev protobuf-compiler \ && rm -rf /var/lib/apt/lists/*docker build -t quillstack-runner:0.0.20 .[profiles.quillstack]image = "quillstack-runner:0.0.20"# cpus, memory and slots as for any profileKeep the entrypoint, and root as the image’s user: the entrypoint starts as root and runs the job as runner. runnerd pulls an image only when Docker lacks it, so build yours before the profile’s first runner. After you update runnerd, rebuild from the new version’s base tag: your image inherits its runnerd.version label, and runnerd doctor warns when that is not the daemon’s version.
Rebuilds install the same versions: Ubuntu packages come from one snapshot date, and every download is checked against its SHA-256 (ADR 0023). Install shows how to check where a published image came from.
Records of failed jobs
Section titled “Records of failed jobs”A profile with record_failures = true keeps the runner of each failed job as a Docker image, so runnerctl shell <job> can open a shell where the job failed (Running). [records] bounds them:
[profiles.jvm]image = "ghcr.io/ayagmar/runnerd-runner:jvm-0.0.20"cpus = 8memory = "9G"slots = 2record_failures = trueA new record pushes out the oldest past max_count or max_total; without room under min_free, the job gets no record, and its page says why. runnerd doctor warns once the records take 80% of max_total.
The budget check
Section titled “The budget check”runnerd check-config [file], by default on ~/.config/runnerd/runnerd.toml, reads the slice’s MemoryMax from systemd, or takes --memory-max, and fails when slots × memory over all profiles plus the mirror does not fit:
$ runnerd check-config runnerd.tomlrunnerd.toml: valid, memory caps sum to 32.5G$ runnerd check-config runnerd.toml --memory-max 24Gerror: slots × memory over all profiles plus the mirror is 32.5G, the slice runnerd.slice's MemoryMax is 24G; lower `slots` or `memory`, or raise the slice's MemoryMax (`runnerd doctor` prints its unit)A slice without a unit file has no MemoryMax; the check then passes with a warning, since nothing would hold the runners to their caps. Under measured admission it holds one runner of each profile, with the mirror, to MemoryMax, and refuses a slice without one. The caps may then add up past MemoryMax, and the check says both figures:
$ runnerd check-config runnerd.toml --memory-max 24Grunnerd.toml: valid under admission = "measured": the largest runner with the mirror needs 9.5G of the 24G MemoryMax; every slot at its cap at once would take 32.5GIsolation
Section titled “Isolation”isolation = "sysbox", which runnerd init writes: each runner container runs its own Docker Engine, unprivileged.
isolation = "unsafe-dind" runs privileged containers, so a job can take over the host. It is refused unless [host] also sets allow_unsafe = true:
$ runnerd check-config runnerd.tomlerror: isolation "unsafe-dind" runs runner containers privileged, so a job can take over the host; set allow_unsafe = true under [host] to use it anywayNever serve public repositories from self-hosted runners: anyone who can open a pull request could run code on your machine.
Every key
Section titled “Every key”A key without a default must be set. Sizes take a binary unit (K, M, G, T), as Docker and systemd use them; durations a number and s, m, h or d, such as "168h" or "7d". The check refuses 0 for a profile’s cpus, memory and slots, an enabled mirror’s memory and cpus, an enabled build cache’s max_size, poll_interval, records.max_total and max_count, a profile’s max_slots when set, and sizing.min_jobs, interval, raise_interval and max_step. A config saved from the dashboard or PUT /v1/config applies without a restart, except [web], history.database and caches.build.directory, which runnerd reads when it starts.
[host]
Section titled “[host]”| Key | Default | Means |
|---|---|---|
slice |
the systemd slice every runner container runs in; its MemoryMax is the budget |
|
isolation |
"sysbox" or "unsafe-dind" (Isolation) |
|
allow_unsafe |
false |
must be true for isolation = "unsafe-dind" |
poll_interval |
"15s" |
how often runnerd asks GitHub for queued jobs |
admission |
"caps" |
what a runner reserves before it starts: its profile’s memory, or with "measured" its job’s predicted peak (Measured admission) |
cpu_overcommit |
unset | a factor above 0, at most 16; set, a runner also has to fit the slice’s CPUs times it (CPU admission) |
[caches]
Section titled “[caches]”| Key | Default | Means |
|---|---|---|
mirror.enabled |
whether runners pull from Docker Hub through the mirror | |
mirror.memory, mirror.cpus |
the mirror container’s limits; its memory counts in the budget |
|
mirror.ttl |
how long the mirror keeps an image no runner pulls again | |
mirror.max_size |
"10G" |
the mirror’s storage is emptied above this when no runner runs, with the shared caches |
pnpm.max_size, maven.max_size |
the shared pnpm store and Maven repository are emptied above this when no runner runs; warm runners stop for it at most hourly, at the first moment no job runs | |
reseed_at |
"2G" |
once slot 1’s layers changed this much and the profile is idle, they become the profile’s snapshot (Warm caches) |
snapshot_max |
"10G" |
a reseed that would make a snapshot larger starts it over from the image, and every slot of the profile runs its next job cold; a snapshot measured larger is reseeded once its profile is idle, whatever reseed_at says |
build.enabled |
true |
whether runners get the shared build cache |
build.max_size |
"10G" |
above this, the build cache’s least recently used entries go when no runner runs, with the shared caches; between two checks it grows to twice this at most |
build.branches |
[] |
the branches whose pushes, scheduled and manual runs write to the build cache besides each repository’s default branch, which always does |
build.directory |
$XDG_CACHE_HOME/runnerd/build-cache |
where the build cache’s entries are kept; read when runnerd starts |
[profiles.<name>]
Section titled “[profiles.<name>]”| Key | Default | Means |
|---|---|---|
image or image_stage |
exactly one: a published or your own image to pull, or a stage of image/Dockerfile built on this host (Runner images) |
|
cpus, memory |
each runner’s CPU and memory limits; memory is its container’s hard limit |
|
slots |
the most runners of the profile at once | |
record_failures |
false |
keep each failed job’s runner as a record, to open a shell in |
min_memory, max_memory |
unset | the bounds auto sizing keeps memory in; unset, as low as its jobs need and as high as MemoryMax holds |
max_slots |
unset | the most slots auto sizing raises the profile to; unset, as many as MemoryMax holds |
priced_as |
actions_linux |
the GitHub-hosted runner its jobs are priced at, by GitHub’s billing name: actions_linux_slim, actions_linux, actions_linux_arm, linux_2_core_advanced, linux_4_core to linux_96_core, linux_2_core_arm to linux_64_core_arm. actions_linux_arm on an arm64 host |
The config check refuses a memory or slots outside the profile’s own bounds.
[[repositories]]
Section titled “[[repositories]]”| Key | Default | Means |
|---|---|---|
name |
owner/name on GitHub; repeat an entry to serve the repository in several profiles |
|
profile |
the profile its jobs run on; one entry per profile for a repository | |
labels |
at least one; a job runs here when every label in its runs-on is one of these; no two entries of a repository share one |
|
warm |
1 |
runners kept registered for jobs not queued yet while the repository is active (warm runners); 0 turns them off |
build_cache |
true |
whether its runners get the shared build cache |
build_cache_writers |
"default-branch" |
whose jobs write to the build cache: "default-branch", the default branch’s and build.branches’ pushes, schedules and manual runs; "pull-requests", those and pull requests people opened from the repository’s own branches |
[history]
Section titled “[history]”| Key | Default | Means |
|---|---|---|
database |
$XDG_STATE_HOME/runnerd/runnerd.db |
the job history’s SQLite file |
samples |
"7d" |
how long a job’s CPU, memory and network samples are kept; its totals and peaks stay with the job |
logs |
"30d" |
how long a finished job’s log is kept |
events |
"30d" |
how long events are kept for clients that resume with Last-Event-ID |
jobs |
"90d" |
how long a finished job’s record is kept, with its steps, totals and runner |
log_max |
"50M" |
the most log kept per job; later lines are dropped |
[records]
Section titled “[records]”| Key | Default | Means |
|---|---|---|
max_total |
"20G" |
the disk all records may take together; runnerd doctor warns at 80% |
max_count |
10 |
the most records kept |
max_age |
"7d" |
how long a record is kept |
min_free |
"10G" |
the disk Docker must keep free: no record is made that would leave less |
[sizing]
Section titled “[sizing]”| Key | Default | Means |
|---|---|---|
margin |
15 |
the percent added to a sampled peak, in advice and predictions; advice adds at least 1G |
min_jobs |
10 |
the fewest runs of a profile, or of one job, before its peaks become advice or a prediction |
auto |
false |
runnerd sizes each profile’s memory, slots and cpus itself (Auto sizing); runnerd init writes true |
interval |
"1h" |
auto sizing: the least time between a change of one profile and one that lowers its memory or cpus |
raise_interval |
"1h" |
auto sizing: the least time between a change of one profile and one that only raises it; at most interval |
max_step |
50 |
auto sizing: the most one change lowers a profile’s memory or moves its cpus, in percent of them; a raise of memory after a run met the cap goes straight to the need |
| Key | Default | Means |
|---|---|---|
origins |
[] |
addresses of proxies in front of the dashboard whose pages may write: pause, reseed, save the config (From another device). Each is a scheme, host and port as the browser’s address bar shows them, with no path, and an IPv4 address as four decimal numbers: https, or http for a loopback host only, since the session cookie would otherwise cross the network in the clear |
open |
none | the one of origins that runnerctl web opens the dashboard on, such as the tailnet’s address; it still prints the loopback link. check-config refuses an open not in origins. runnerctl web --local or --base overrides it for one run |
[notify]
Section titled “[notify]”Where runnerd sends notifications. Without targets nothing is sent.
| Key | Default | Means |
|---|---|---|
digest_at |
"09:00" |
when the digest goes to targets that list digest, HH:MM in the host’s local time |
Each [[notify.targets]]:
| Key | Default | Means |
|---|---|---|
name |
required | what logs and runnerctl notify test call it; where it got to is kept under this name |
kind |
required | "ntfy", "discord" or "webhook" |
url |
required | an ntfy topic’s URL (https://ntfy.sh/<topic>), a Discord webhook’s (https://discord.com/api/webhooks/123/abc) or the webhook’s, where it answers without a redirect: a redirect counts as a failed send; a secret that no log line, error or client shows |
headers |
{} |
sent with each post, such as { Authorization = "Bearer tk_…" }; their values are secrets too |
events |
["failed", "lost", "runner", "insights"] |
what it is sent; add "digest" for the daily digest |
The dashboard’s config editor, runnerctl config show and GET /v1/config show url and header values as "<secret>". Leave them so and saving keeps the values in the file; write a new value over one to change it. A target renamed in the editor has nothing to keep, so write its values again. Comments are shown as written.