Self-hosted runner setup runbook¶
Per ADR-0098 — opt-in runner pattern. Heavy workflows (
go-security/image-scan/codeql/ztp-prem-*/dashboard-visual) migrate to your self-hosted box when this is wired; everything else stays on GitHub-hosted ephemeral runners. When the runner is offline OR the repo variable is unset, all workflows transparently fall back toubuntu-latest(zero-disruption pattern).
Why¶
Driver: GitHub Actions Linux billing was the constraint. ADR-0097 cut ~60-75% of recurring cost; this runbook (ADR-0098) brings the heavy crons down to ~zero recurring spend by hosting them on operator hardware.
Pre-requisites¶
- One Linux machine (or macOS / Windows — workflows assume Linux
in
actions/setup-gocache paths; macOS works for everything except thesecurego/gosecDocker action, which is Linux-only) - Recommended specs: 4+ cores, 8 GiB RAM, 50 GiB disk, stable IP
- outbound HTTPS to
*.actions.githubusercontent.com - Tools installed on the host (matches
ubuntu-latestrunner image): docker(Docker Engine 24+) — gosec + image-scan + codeql + the dashboard-e2eservices: postgres:16-alpineall use containers. The runner user must be in thedockergroup (see Step 1).git,curl,jq,unzip,tar,python3≥ 3.10,bash≥ 4ghCLI (optional — only for ad-hoc operator debugging)build-essential+pkg-config— required sosetup-go@v5builds cgo-using packages (e.g.github.com/miekg/pkcs11). Without gcc on PATH, Go defaultsCGO_ENABLED=0and cgo packages silently expose no symbols (vet: undefined: pkcs11.Ctx).libpkcs11-helper1-dev— header for the same pkcs11 package.- Go is auto-installed per-job by
actions/setup-go@v5from each module'sgo.mod; no system Go needed.
Step 1 — Provision the host¶
Any Ubuntu 24.04 / Debian 12 box works. Quick check:
docker --version # Docker Engine 24+
git --version # any 2.x
jq --version # any 1.x
python3 --version # ≥ 3.10
free -h # ≥ 8 GiB available
df -h / # ≥ 50 GiB free
If Docker isn't installed:
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
newgrp docker
docker run --rm hello-world # smoke
Install the remaining system deps in one shot:
sudo apt-get update
sudo apt-get install -y \
git curl jq python3 ca-certificates gnupg unzip tar \
build-essential pkg-config libpkcs11-helper1-dev
Why
build-essential?setup-go@v5won't enable cgo unless a C compiler is onPATH. cgo-using Go packages (e.g.github.com/miekg/pkcs11inpkg/octopus/common/hsm) silently degrade to empty exports when CGO is off, andgo vetthen fails withundefined: pkcs11.Ctx. Surfaced when theoctopus-common-logcoverage gate first ran on the self-hosted arm64 runner (PR #1093).
Step 2 — Register the runner with GitHub¶
- In the repo on GitHub: Settings → Actions → Runners → New self-hosted runner.
- Pick Linux x64.
- GitHub shows a registration token and the exact commands. Run
them on your host, but add the custom label
heavyin the config.sh step (the shell script GitHub's installer drops at~/actions-runner/config.sh):
# Inside ~/actions-runner (created by GitHub's instructions):
./config.sh --url https://github.com/<owner>/<repo> \
--token <REGISTRATION_TOKEN> \
--labels "heavy" \
--name "$(hostname)-heavy" \
--work _work \
--unattended
The runner now reports labels self-hosted, Linux, X64, heavy.
- Install it as a systemd service so it survives reboots:
sudo ./svc.sh install "$USER"
sudo ./svc.sh start
sudo ./svc.sh status
- Confirm it shows green online in Settings → Actions → Runners.
Step 3 — Flip the repo variable¶
This is the switch that moves heavy workflows from GitHub-hosted
to your runner. Without this set, workflows stay on ubuntu-latest.
In the repo on GitHub: Settings → Secrets and variables → Actions → Variables → New repository variable:
| Field | Value |
|---|---|
| Name | HEAVY_RUNNER |
| Value | ["self-hosted", "Linux", "X64", "heavy"] |
The JSON-array format matches what fromJSON expects in the workflow
files. Do not wrap it in extra quotes.
To verify, kick off a manual workflow_dispatch run:
gh workflow run go-security.yml
gh run watch
The job log shows Runner name: <hostname>-heavy instead of
Runner Image: ubuntu-24.04. Cost confirmed flipped.
Step 4 — Verify zero-disruption fallback¶
Test that disabling the runner does not break the workflows.
# Stop the runner
sudo ./svc.sh stop
# Trigger any heavy workflow manually
gh workflow run go-security.yml
# It should land on GitHub-hosted ubuntu-latest after the queue
# timeout (~1min), NOT fail. The fork-PR-safety condition forces
# GitHub-hosted in that fallback path.
If runs HANG instead of falling back, unset HEAVY_RUNNER:
In Settings → Variables → HEAVY_RUNNER → Delete. All heavy
workflows return to GitHub-hosted immediately.
Restart the runner when you're ready.
Step 5 — Maintain¶
Update the runner¶
GitHub releases new runner versions monthly. Update with:
sudo ./svc.sh stop
# Re-download the latest runner package per GitHub Settings → Runners
# instructions (the URL changes; check the page).
sudo ./svc.sh start
Disk hygiene¶
The runner caches Go modules, Docker layers, and npm. Clean periodically:
# In runner home
rm -rf _work/_actions
docker system prune -af
Add to weekly cron:
0 6 * * 0 cd /home/runner/actions-runner && rm -rf _work/_actions && docker system prune -af
Monitor health¶
sudo systemctl status actions.runner.* # systemd unit name varies by GitHub
journalctl -u actions.runner.* -f # tail logs
If the runner reports offline >5min, the heavy workflows queue up.
After ~10min queue, GitHub typically retries on the same runner
(no auto-fallback to GitHub-hosted — operator must intervene by
unsetting HEAVY_RUNNER or fixing the runner).
Scaling up: multiple instances + host tuning¶
Steps 1-5 set up one runner on a modest box. A powerful host (many cores, lots of RAM) is wasted running a single instance — a runner processes one job at a time. Run N instances to use the box, and tune the host so parallel CI jobs don't thrash.
How many instances? (right-sizing)¶
A runner instance does not reserve CPU/RAM — it only gates concurrency (1 job per instance). N instances = up to N concurrent jobs sharing the whole box via the OS scheduler. Oversubscription (more instances than the box can run at once) causes thrash → slow + flaky jobs (timeouts, OOM). A lone job already gets the whole machine; extra instances only help when several jobs land at once.
Rule of thumb: ~1 instance per 2-3 vCPUs, dialed down on boxes with tight RAM/disk or weak CPUs. Reliability beats raw parallelism for CI — the matrix wall-clock is gated by the slowest job, so a flaky oversubscribed box is a long pole.
Reference fleet (2026-06):
| Box | CPU | RAM | Instances | Role |
|---|---|---|---|---|
cpu-gamer |
i7-8700 (12t) | 62 GiB | 4 | primary |
nuc-intel |
i7-8809G (8t) | 31 GiB | 2 | secondary |
nuc-gigabyte |
i7-4500U (4t ULV) | 15 GiB | 1 | backup |
tlsstress-computer-2 |
Ryzen 5 4600G (6c/12t) | 30 GiB | 6 | heavy — temporary (reverts to NGFW dataplane) |
⚠️
tlsstress-computer-2is a temporary loan. Its permanent role is firewall/NGFW load testing — it normally runs thetunedcpu-partitioningprofile (isolcpus=1-5,7-11→ only 2 housekeeping threads schedulable). It was converted to a heavy runner on 2026-06-17 by switching tothroughput-performance+ strippingisolcpus/hugepages from GRUB (frees all 12 threads). The original tuning is backed up on the box at~/runner-tuning-backup/with a one-shotrevert-to-dataplane.sh. To return it to dataplane duty: run that script +sudo reboot, then uninstall the 6 runner services and deregister viagh api -X DELETE repos/<owner>/<repo>/actions/runners/<id>.
Tiered routing: fast (strong-only) vs heavy (full pool)¶
Adding weak boxes to a flat heavy pool has limited upside — a heavy job
(image-scan, dashboard-e2e/visual) landing on a weak box becomes the
matrix long-pole. Split work by weight:
- Strong boxes carry an extra
fastlabel; repo variableFAST_RUNNER = ["self-hosted","Linux","X64","fast"]. - The heaviest workflows (
image-scan,dashboard-e2e,dashboard-visual) use afast-first fallback chain — strong boxes only:
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || fromJSON(vars.FAST_RUNNER || vars.HEAVY_RUNNER || '"ubuntu-latest"') }}
FAST_RUNNER if set → else HEAVY_RUNNER → else ubuntu-latest
(zero-disruption preserved; fork PRs stay GitHub-hosted).
- Light, high-count matrices (go-security's ~47-module gosec/
govulncheck) stay on HEAVY_RUNNER (full pool) — that's where weak boxes
earn their keep: many short jobs, more slots = faster drain.
- codeql stays on ubuntu-latest by design (memory-bound; self-hosting
doesn't help).
Add the fast label to a registered runner without reconfiguring it:
echo '{"labels":["fast"]}' | gh api --method POST \
repos/<owner>/<repo>/actions/runners/<id>/labels --input -
Add N instances¶
Each instance is its own directory + systemd service; reuse the one
downloaded runner tarball. Mint a fresh registration token per
instance (gh api -X POST repos/<owner>/<repo>/actions/runners/registration-token --jq .token):
URL=https://github.com/<owner>/<repo>
TARBALL=$(ls ~/actions-runner/actions-runner-linux-x64-*.tar.gz | head -1)
for i in $(seq 1 N); do
D=~/runners/$(hostname)-$i; rm -rf "$D"; mkdir -p "$D"; tar xzf "$TARBALL" -C "$D"
( cd "$D"
./config.sh --unattended --replace --url "$URL" --token "<FRESH_TOKEN_$i>" \
--name "$(hostname)-$i" --labels "heavy" --work _work
sudo ./svc.sh install "$USER" && sudo ./svc.sh start )
done
Right-size DOWN (remove instances)¶
# On the box, per instance to remove:
( cd ~/runners/<name>-<i> && sudo ./svc.sh stop && sudo ./svc.sh uninstall )
sudo rm -rf ~/runners/<name>-<i> # sudo: job _work holds root-owned docker files
gh api -X DELETE repos/<owner>/<repo>/actions/runners/<runner_id>
Host tuning¶
Drain first so a docker restart doesn't corrupt a running job's containers, then restart the runner services after:
sudo systemctl stop 'actions.runner.*' # ... apply tuning ... then:
sudo systemctl start 'actions.runner.*'
1. Disk — extend root into free LVM extents (Ubuntu installs often leave the root LV far smaller than the disk):
sudo lvextend -l +100%FREE -r "$(findmnt -no SOURCE /)"
⚠️
+100%FREEconsumes all free VG extents into root. Intended for a dedicated CI box (more space for docker layers /_work). If you reserve VG space for other LVs/snapshots, extend by a fixed size instead (-L +200G). LV shrink is risky — size up deliberately.
2. sysctls — /etc/sysctl.d/99-ci-runner.conf then sudo sysctl --system:
net.core.rmem_max=16777216
net.core.wmem_max=16777216
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
net.ipv4.ip_local_port_range=1024 65535
fs.inotify.max_user_watches=524288
fs.inotify.max_user_instances=512
3. Docker — /etc/docker/daemon.json ⚠️ critical when the runner
LAN is 172.17.x (our VLAN 30): Docker's default docker0 bridge is
172.17.0.0/16, which overlaps the runner LAN → container
networking and routes black-hole. Move docker off 172.17, then
sudo systemctl restart docker:
{
"bip": "10.200.0.1/24",
"default-address-pools": [
{"base": "10.201.0.0/16", "size": 24},
{"base": "10.202.0.0/16", "size": 24}
],
"max-concurrent-downloads": 8,
"max-concurrent-uploads": 8,
"log-driver": "json-file",
"log-opts": {"max-size": "10m", "max-file": "3"},
"builder": {"gc": {"enabled": true, "defaultKeepStorage": "10GB"}}
}
4. CPU governor → performance (persistent systemd unit):
sudo tee /etc/systemd/system/cpu-governor.service >/dev/null <<'EOF'
[Unit]
Description=Set CPU governor to performance for CI
After=multi-user.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c 'for g in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do echo performance > "$g"; done'
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now cpu-governor.service
5. Weekly docker prune — /etc/cron.weekly/docker-prune (chmod +x):
#!/bin/sh
docker system prune -af --filter "until=168h" >/dev/null 2>&1
Security note on multi-instance boxes¶
Persistent runners reuse _work across jobs (not ephemeral). Keep the
box single-repo, run under a dedicated unprivileged user, and rely on
the weekly prune + the fork-PR guardrail below. For full per-job
isolation you'd need ephemeral runners + an external re-registration
loop (out of scope here).
Security guardrails¶
The workflow runs-on: clause is:
runs-on: ${{ github.event.pull_request.head.repo.fork && 'ubuntu-latest' || fromJSON(vars.HEAVY_RUNNER || '"ubuntu-latest"') }}
This guarantees:
- Fork PRs ALWAYS use GitHub-hosted ephemeral runners. Untrusted code from external contributors never touches the operator's self-hosted machine.
HEAVY_RUNNERunset → ubuntu-latest. Zero-disruption deploy of this PR — workflows keep working until operator wires Step 3.- Runner offline → GitHub queues — does NOT fall back. Operator monitors via Step 5.
Hardening (recommended)¶
- Run the runner under a dedicated user account (e.g.
runner) with no sudo, no SSH from outside. - Keep the runner directory on an encrypted disk (LUKS).
- Restrict outbound network on the host to GitHub IPs only (see https://api.github.com/meta).
- Do NOT add the runner to multiple repos — separation of concerns; if any one workflow gets compromised, blast radius stays small.
- Run inside a VM or LXC container — easy reset if something goes wrong.
Workflows that DO use self-hosted (when HEAVY_RUNNER is set)¶
| Workflow | Heavy? | Why on self-hosted |
|---|---|---|
go-security.yml |
yes — 47-module matrix | gosec + govulncheck per module ≈ 2-3min × 47 = ~2h on GitHub-hosted |
image-scan.yml |
yes — 3 Docker builds + trivy | image builds are bandwidth + disk-heavy |
codeql.yml |
yes — JS/TS deep scan | CodeQL is single-threaded + memory-bound |
ztp-prem-tier-b-obfuscation.yml |
yes — garble + multi-module obfuscation | weekly cron after ADR-0097 |
ztp-prem-sod-audit.yml |
yes — full-repo audit | weekly cron after ADR-0097 |
dashboard-visual.yml |
yes — playwright + screenshots | weekly cron after ADR-0097 |
Workflows that STAY on GitHub-hosted (always)¶
| Workflow | Why not migrated |
|---|---|
ci.yml |
Primary PR gate — needs fast feedback + ephemeral isolation; runs constantly |
secret-scan.yml |
Tiny + security-sensitive — gitleaks should run in a sterile env |
dco-check.yml / changelog-fragment-check.yml / check-file-size.yml |
Tiny — moving them adds queueing overhead with no cost win |
release.yml / tag-signature-verify.yml |
Signing & release events — sterile env mandatory |
forensic-tamper-check.yml |
Reads FORENSIC_FINGERPRINTS secret — sterile env |
doc-audit.yml / module-conformance.yml |
Tiny, PR-trigger only |
Cost expectation¶
| Scenario | Monthly cost |
|---|---|
| No self-hosted (current) | Whatever the heavy crons + PR runs total per ADR-0097 (60-75% lower than pre-cost-cut) |
| Self-hosted + everything ADR-0097 | ~zero recurring cost on Linux Actions billing. The remaining GitHub-hosted minutes are fast PR gates (ci.yml + tiny checks) only. |
Electricity + hardware are off-books — operator's call whether the trade is worth it.
Rollback¶
If the runner becomes a problem:
# Stop + uninstall service
sudo ./svc.sh stop
sudo ./svc.sh uninstall
# Unset the repo variable in Settings → Variables → HEAVY_RUNNER → Delete
# Remove the runner from GitHub Settings → Actions → Runners → click
# the runner → Remove
All workflows resume on ubuntu-latest immediately. No code change.
References¶
- ADR-0097 — CI cost-cut radical (preceded this; got 60-75% reduction without hardware)
- ADR-0098 — Self-hosted runner opt-in pattern (this runbook's source of truth)
- GitHub docs — https://docs.github.com/en/actions/hosting-your-own-runners
- GitHub security hardening — https://docs.github.com/en/actions/hosting-your-own-runners/security-hardening-for-self-hosted-runners