Skip to content

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.

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 = 8
memory = "9G"
slots = 2
[profiles.node]
image = "ghcr.io/ayagmar/runnerd-runner:node-0.0.20"
cpus = 8
memory = "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"]

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, or All 2 node slots are busy.
  • In runnerctl sizing, its row has a P95 peak far under the profile’s memory and a WAIT P95 as 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 = 2
memory = "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.

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:

Terminal window
docker build --target jvm -t runnerd-runner:jvm image

At 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.

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.20
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
Terminal window
docker build -t quillstack-runner:0.0.20 .
[profiles.quillstack]
image = "quillstack-runner:0.0.20"
# cpus, memory and slots as for any profile

Keep 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.

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 = 8
memory = "9G"
slots = 2
record_failures = true

A 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 memory budgetrunnerd check-config adds every slot's memory cap and the mirror's: two jvm slots of 9G, two node slots of 7G and 512M for the mirror make 32.5G, which fits a MemoryMax of 33G. Each tick, what runs takes its cap: here jvm 1, node 1, a shell in a jvm record at 9G and the mirror use 25.5G, leaving 7.5G. A queued node job needs 7G, which fits, so it starts in node 2. A queued jvm job needs 9G, which does not fit, so it waits in GitHub's queue though jvm 2 is free.runnerd check-configevery slot's cap and the mirror: 32.5G of 33Gjvm 19Gjvm 29Gnode 17Gnode 27Gmirror 512M, 512M freeeach tickwhat runs takes its cap; a job starts if its capfits what is leftjvm 19Gnode 17Gjvm shell9G7.5G free7Ga node job fits: it starts in node 29Ga jvm job waits, though jvm 2 is free
The example config in a slice with MemoryMax=33G, to scale. A shell in a record and a reseed take memory too, so a free slot is not always room.

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.toml
runnerd.toml: valid, memory caps sum to 32.5G
$ runnerd check-config runnerd.toml --memory-max 24G
error: 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 24G
runnerd.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.5G

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.toml
error: isolation "unsafe-dind" runs runner containers privileged, so a job can take over the host; set allow_unsafe = true under [host] to use it anyway

Never serve public repositories from self-hosted runners: anyone who can open a pull request could run code on your machine.

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.

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)
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
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.

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
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
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
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

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.