jojomojo786 · self-hosted CI · FHLS-Server-Hetzner

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.

Dispatch simulator — press a scenario, watch the schematic
github.com · job queue
FHLS-1 · 162.55.135.130 · 12 cores / 62 GB
FHLS-Server-Hetzner
self-hosted · fhls · hetzner · debian-12
FHLS-Server-Hetzner-2
self-hosted · fhls · hetzner · debian-12
/ (md2)20%
/home (md3 · 9.1 TB)80%
abosimple-postgres-tunnel · 127.0.0.1:15432 → Postgres VM

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:

workflow yaml
runs-on: [self-hosted, fhls]

The constraint that shapes everything: runners exist at exactly three scopes, and a personal account only gets the narrowest.

ScopeServesYou
Repositoryexactly one repo — no sharing, everwhat you use now
Organizationevery repo in the org, one shared poolonly if repos move into an org
Enterpriseevery orgn/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.

  1. Mint a registration token (valid 1 hour)

    macbook / macmini / pc
    TOKEN=$(gh api -X POST repos/jojomojo786/NEWREPO/actions/runners/registration-token --jq .token)
  2. 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-Hetzner
    mkdir -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
  3. Register — keep the labels identical

    on FHLS-Server-Hetzner
    sudo -u github-runner ./config.sh --unattended \
      --url https://github.com/jojomojo786/NEWREPO \
      --token "$TOKEN" \
      --name FHLS-NEWREPO \
      --labels hetzner,debian-12,fhls \
      --work _work
  4. Bind-mount _work onto /home — before the first job

    The 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-Hetzner
    mkdir -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"
  5. 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
  6. Verify, then wire the repo

    macbook
    gh api repos/jojomojo786/NEWREPO/actions/runners \
      --jq '.runners[] | "\(.name) \(.status) \([.labels[].name]|join(","))"'
    # expect: FHLS-NEWREPO online self-hosted,Linux,X64,hetzner,debian-12,fhls

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

macbook
gh api repos/jojomojo786/REPO/actions/runners --jq '.runners[] | "\(.name): \(.status) busy=\(.busy)"'

Where did this run execute — and did it cost anything?

macbook
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, free

What is the runner doing right now?

on FHLS-Server-Hetzner
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 reposVerdict
1–3Per-repo runners are fine. (You're here — 1 repo.)
4–9Workable — 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.
MachineRoot SSH to boxgh auth
MacBookadeel@macbook-fhlsjojomojo786
Mac miniadeel@macmini-fhlsjojomojo786
Gaming PCroot@gamingpc-fhlsjojomojo786