CI runners on your own box

runnerd runs your GitHub Actions jobs on one Linux machine, each in a fresh container, as many at once as its memory holds.

The runnerd dashboard: 2 of 6 slots running, 5 jobs waiting with why each waits, the memory budget as blocks per slot, the slots with their jobs' memory and CPU, and the latest jobs.The runnerd dashboard: 2 of 6 slots running, 5 jobs waiting with why each waits, the memory budget as blocks per slot, the slots with their jobs' memory and CPU, and the latest jobs.

Twice the runners in the same 33G

Each runner reserves what its job really peaks at, measured over its last 20 runs, instead of a fixed cap. A job that runs out of memory gets its full cap back.

How admission works

4 jobs at once, longest wait 8m00s

  • halyard/ledger CI / integration tests: 9G
  • halyard/ledger CI / build: 9G
  • halyard/storefront CI / build: 7G
  • halyard/storefront CI / build: 7G
  • mirror, 512M
  • 512M free
jvm node. The busiest moment of a replayed 28-day history: 3 jvm and 5 node jobs queued together.

8 jobs at once, longest wait 9m00s

  • halyard/ledger CI / integration tests: 8.5G
  • halyard/ledger CI / build: 5G
  • halyard/ledger CI / build: 5G
  • halyard/storefront CI / build: 2.25G
  • halyard/storefront CI / build: 2.25G
  • halyard/storefront CI / build: 2.25G
  • halyard/storefront CI / build: 2.25G
  • halyard/storefront CI / e2e: 2.5G
  • mirror, 512M
  • 2.5G free
jvm node. The busiest moment of a replayed 28-day history: 3 jvm and 5 node jobs queued together.

Every wait says why

A queued job names what holds it: busy slots, a runner still starting, a label nothing serves. A typo in runs-on shows up before you go looking for it.

$ runnerctl status
WORKFLOW / NAME    QUEUED      WAITS FOR
CI / lint          5m 23s ago  No profile serves the label storefornt-small
E2E / visual-diff  1m 14s ago  A runner is starting for it in node 2
E2E / a11y         1m 8s ago   All 2 node slots are busy
E2E / lighthouse   55s ago     All 2 node slots are busy

A shell in the job that failed

With record_failures = true, a failed job's container is kept. runnerctl shell opens it at the step that failed, with its files as the job left them.

Records of failed jobs
$ runnerctl shell 120055991489
runnerd: the record of job 120055991489 of halyard/ledger, CI / build
It failed at step 5: Unit tests (exit code 1)
Its log shows it ran:
    ./gradlew test --no-daemon
    shell: /usr/bin/bash -e {0}
You are in /home/runner/_work/ledger/ledger, as the job left it. Exit to remove this container.
runner@4be1c0d2a7e9:~/_work/ledger/ledger$

What GitHub would have charged

$13.10 this month

runnerd prices each job at GitHub's per-minute rate, rounded up to the minute as GitHub bills, before your plan's free minutes. Public repositories on standard runners count as free.

2,184 minutes of 412 jobs at actions_linux (2-core Linux, $0.006 a minute): $13.10
9 jobs free on GitHub: on a standard runner, of a public repository
5 jobs not priced: lost, so how long they ran is not known

Watch it from anywhere

A dashboard you can install as an app on your phone, a terminal UI, and runnerctl for scripts. All three read the same daemon.

The clients
A running job in the dashboard: its steps on one time axis, memory and CPU against what it reserved, and its latest runs.A running job in the dashboard: its steps on one time axis, memory and CPU against what it reserved, and its latest runs.

Running in three steps

  1. Install

    A static binary for x86-64 Linux, into ~/.local/bin.

    curl -fsSL https://github.com/ayagmar/runnerd/releases/latest/download/install.sh | sh
  2. Point it at your repositories and start it

    init sizes the slots to your machine, writes the config and the service, and prints any root commands to run before you start it.

    runnerd init halyard/ledger halyard/storefront
    systemctl --user daemon-reload
    systemctl --user enable --now runnerd
  3. Send jobs to it

    In each workflow that should run here.

    runs-on: [self-hosted, linux, x64]
Read the install guide