FHLS Runner Academy
An interactive tour of how your GitHub Actions jobs run on your own hardware — built from the incidents of the org→personal migration, so you can break things here instead of on the box.
01The mental model
A runner is a small agent on your box that long-polls GitHub. When a job's runs-on labels match labels the runner carries, GitHub hands the job over. Every runner here registers the same custom labels — so this identical line works in every repo:
runs-on: [self-hosted, fhls]
The constraint that shapes everything: runners exist at exactly three scopes, and a personal account only gets the narrowest.
| Scope | Serves | You |
|---|---|---|
| Repository | exactly one repo — no sharing, ever | what you use now |
| Organization | every repo in the org, one shared pool | only if repos move into an org |
| Enterprise | every org | n/a |
There is no user-account scope. Each new repo that needs CI gets its own runner registration on the box — its own directory, systemd service, and _work mount. Idle runners are cheap (~145 MB each); the recurring cost is the setup in §02, and the fact that repo B's idle runners can never help repo A — try “Busy afternoon” in the simulator to feel it.
02Wire a new repo to the box
Substitute NEWREPO, pick N = next free /opt/actions-runner-N. Step 1 runs on any of your machines; the rest on the box.
-
Mint a registration token (valid 1 hour)
macbook / macmini / pcTOKEN=$(gh api -X POST repos/jojomojo786/NEWREPO/actions/runners/registration-token --jq .token)
-
Install the runner software
Match the version the existing runners report (
/opt/actions-runner/bin/Runner.Listener --version); auto-update keeps it current afterwards.on FHLS-Server-Hetznermkdir -p /opt/actions-runner-N && cd /opt/actions-runner-N curl -sL -o r.tar.gz https://github.com/actions/runner/releases/download/v2.336.0/actions-runner-linux-x64-2.336.0.tar.gz tar xzf r.tar.gz && rm r.tar.gz chown -R github-runner:github-runner /opt/actions-runner-N
-
Register — keep the labels identical
on FHLS-Server-Hetznersudo -u github-runner ./config.sh --unattended \ --url https://github.com/jojomojo786/NEWREPO \ --token "$TOKEN" \ --name FHLS-NEWREPO \ --labels hetzner,debian-12,fhls \ --work _work -
Bind-mount
_workonto /home — before the first jobThe runner tooling knows nothing about this. Skip it and the workspace lands on the 160 GB root disk — flip the toggle off in the simulator and press “Runaway build” to watch what that costs.
on FHLS-Server-Hetznermkdir -p /home/github-runner/FHLS-NEWREPO-work chown github-runner:github-runner /home/github-runner/FHLS-NEWREPO-work UNIT=$(systemd-escape -p --suffix=mount /opt/actions-runner-N/_work) cat > "/etc/systemd/system/$UNIT" <<EOF [Unit] Description=GitHub Actions runner N workspace on /home RequiresMountsFor=/home Before=actions.runner.jojomojo786-NEWREPO.FHLS-NEWREPO.service [Mount] What=/home/github-runner/FHLS-NEWREPO-work Where=/opt/actions-runner-N/_work Type=none Options=bind [Install] WantedBy=multi-user.target EOF SVC=actions.runner.jojomojo786-NEWREPO.FHLS-NEWREPO.service mkdir -p "/etc/systemd/system/$SVC.d" printf '[Unit]\nRequires=%s\nAfter=%s\n' "$UNIT" "$UNIT" \ > "/etc/systemd/system/$SVC.d/workspace-mount.conf" systemctl daemon-reload && systemctl enable --now "$UNIT"
-
Cleanup hooks, then install & start the service
on FHLS-Server-Hetzner# per-job workspace cleanup — same hook the other runners use echo 'ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/opt/github-runner-hooks/cleanup-workspace.sh' \ >> /opt/actions-runner-N/.env # daily stale-cleanup hardcodes its roots — add "/opt/actions-runner-N/_work" to: nano /opt/github-runner-hooks/cleanup-stale-workspaces.sh # growing caches belong on md3 too (npm cache, Playwright browsers, runner logs) — # jobs' HOME is the install dir, so npm ci would otherwise fatten the root disk mkdir -p /home/github-runner/runner-N-cache chown github-runner:github-runner /home/github-runner/runner-N-cache for d in .npm .cache _diag; do mkdir -p /home/github-runner/runner-N-cache/$d chown github-runner:github-runner /home/github-runner/runner-N-cache/$d ln -s /home/github-runner/runner-N-cache/$d /opt/actions-runner-N/$d done cd /opt/actions-runner-N ./svc.sh install github-runner && ./svc.sh start
-
Verify, then wire the repo
macbookgh api repos/jojomojo786/NEWREPO/actions/runners \ --jq '.runners[] | "\(.name) \(.status) \([.labels[].name]|join(","))"' # expect: FHLS-NEWREPO online self-hosted,Linux,X64,hetzner,debian-12,fhlsWorkflows use
runs-on: [self-hosted, fhls]; secrets are per-repo too —gh secret set NAME -R jojomojo786/NEWREPO.
03Incident drills
Every symptom below actually happened on this setup. Diagnose it faster than the first time.
04Verify & debug cheatsheet
Is the runner up? Is it busy?
gh api repos/jojomojo786/REPO/actions/runners --jq '.runners[] | "\(.name): \(.status) busy=\(.busy)"'
Where did this run execute — and did it cost anything?
gh api repos/jojomojo786/REPO/actions/runs/RUN_ID/jobs --jq '.jobs[] | "\(.name) => \(.conclusion) on \(.runner_name)"'
gh api repos/jojomojo786/REPO/actions/runs/RUN_ID/timing # "billable": {} → self-hosted, freeWhat is the runner doing right now?
journalctl -u actions.runner.jojomojo786-REPO.RUNNER_NAME.service -n 30 --no-pager -o cat findmnt /opt/actions-runner-N/_work # source must say md3 df -h / /home # root should hold ~20%
Jobs queue forever?
Label mismatch (workflow asks for a label no online runner has), or every runner for that repo is busy — a repo can only use its own runners. Compare the workflow's runs-on to what the API reports, then check busy.
| CI repos | Verdict |
|---|---|
| 1–3 | Per-repo runners are fine. (You're here — 1 repo.) |
| 4–9 | Workable — script the provisioning or installs drift. |
| 10+ | Move the CI repos into an org, register runners once at org level. Repos transfer in seconds; re-registering took ~20 min last time. Cheap to reverse. |
| Machine | Root SSH to box | gh auth |
|---|---|---|
| MacBook | adeel@macbook-fhls | jojomojo786 |
| Mac mini | adeel@macmini-fhls | jojomojo786 |
| Gaming PC | root@gamingpc-fhls | jojomojo786 |