Series · The Quiet Machine · Part 9
GitHub Actions Self-Hosted Runner on a Windows Gaming PC
Every CI run for this project (backend, Android, web) cold-started a fresh GitHub VM and re-downloaded half of NuGet and Gradle’s internet, every single time. The box, meanwhile, just sat there: Ollama resident, answering embedding queries, doing nothing else. The fix was a self-hosted GitHub Actions runner, running on that same box.
It was idle while a rented machine somewhere else paid, on every run, for having no memory of the run before it.
So the box got a second job.
Why hand CI to a gaming PC
The pitch is boring in the best way. This box has a Ryzen 5 3600 giving WSL2 twelve threads and 26GB of RAM, and 846GB free on D:, none of them doing anything most of the day.
A GitHub-hosted runner has no cache and no warm dependency store; nothing survives between jobs except what you explicitly upload and download again. A runner on my own box keeps its NuGet, Gradle, and npm caches on disk, between builds. The backlog’s own budget sets aside 50–100GB for exactly that.
Cold cloud CI versus a hot local cache isn’t a close fight.
It also costs nothing extra to expose. The runner is outbound-only: it long-polls GitHub for work and never opens an inbound port, the same shape as the SSH tunnel and the Telegram bot earlier in this project.
iOS stays off this machine entirely; there’s no Xcode for Linux, so backend, Android, and web build here, and iOS keeps building on the Mac, same as it always has.
Why the GPU never noticed
This runner is CPU-only, and that decided the build order. Every step (restore, compile, test) runs on Ryzen cores; it never touches the GPU.
That matters because the GPU already has a full-time tenant. Ollama sits resident on the RTX 3060 with roughly 11.6GB of the card’s 12GB pinned, serving the memory framework and day-to-day local inference.
A GPU-hungry service showing up would mean a whole negotiation: unload one model, load the other, hope nothing times out mid-swap. I checked the VRAM number before and after registering the runner:
VRAM: 11919MiB / 12288MiB — before
VRAM: 11919MiB / 12288MiB — after registering the runner
Unchanged, to the megabyte. That’s the whole argument for doing this one first, ahead of the visual-generation service still sitting in the backlog: CPU work and GPU work don’t compete for the same eleven-plus gigabytes, so there’s nothing to schedule around.
The actual setup
The mechanics were unremarkable, which is the point: pull down the Linux runner, install its dependencies, and register it against the org. The one snag worth naming: the runner is a .NET binary under the hood, and .NET on Linux wants libicu for globalization support. Skip that and it fails quietly on first launch.
Registration needs a human once: an org-level token generated fresh from behind a login, good for about an hour, one-time use. Same shape as the Tailscale pre-auth key from Part 4: the tool that wants a browser has to get one; no amount of scripting substitutes for the click.
Then, the same lesson this series already learned the hard way about Tailscale in Part 5: a process running interactively doesn’t survive a reboot. Installed as a background service instead, so it comes back on its own after a reboot, no login required. Confirmed straight from the API instead of a screenshot: online, idle, waiting for a job.
$0 to run, because the hardware’s already mine.
The Mac runner: registered, and left off on purpose
CI needs an iOS leg too, and iOS only builds on a Mac. The same runner binary, osx-arm64, version 2.336.0, went onto the Mac and registered against the same org, labeled mac-ios.
Then I left it offline, on purpose, sitting there ready rather than broken. This is the same restraint as Part 6, where I scoped disablesleep to AC-only instead of flipping it globally just because I could.
The reason is disk math. An iOS build’s DerivedData runs about 20GB; my Mac has roughly 44GB free. That’s two or three unattended builds before the disk complains, and a runner that silently fills its own host’s disk is worse than no runner at all.
The fix is staged, not yet switched on: an ACTIONS_RUNNER_HOOK_JOB_COMPLETED hook, wired through the runner’s .env, pointing at a cleanup script that wipes DerivedData the moment a job finishes. When iOS CI is wanted, it’s ./run.sh or the service install away.
(It has since come online: the DerivedData cleanup hook proved out, and mac-ios now shows status=online alongside the box’s runner — two runners at the time.)
The settings that decide whether this is safe
A self-hosted runner executes whatever a workflow tells it to, on my hardware, with real network access. The org’s Actions settings are the real security boundary here, more than anything I did in WSL2:
- Allowed actions: my org’s own actions plus a curated allow-list of trusted third-party ones, not “allow all,” which would let any public action run unreviewed on my box.
- Default
GITHUB_TOKENpermissions: read-only. Write access has to be requested explicitly, per job. Actions also can’t approve its own pull requests. - Fork PR workflows: approval required for every external contributor, and fork workflows never auto-run against a private repo. It’s the one setting between “a stranger’s PR” and “a stranger’s code executing on my machine.”
- Live debugging: on. Generic advice says leave this off for a team; on a private org where I’m the only one who can trigger a workflow, SSHing into a failing job beats losing the failure.
- Artifact and log retention: dropped from the 90-day default to 14–30 days. No reason to let it pile up past what a solo project ever needs.
- GitHub-hosted runners: left enabled, with the org’s spending limit set to $0. Disabling them closes a door I might want later; zeroing the budget keeps the door unlocked while making it mechanically impossible to ever get billed for it.
The rest of the box’s new jobs
Two more services went live alongside the runner: Uptime Kuma, watching the box’s own services and firing a Telegram alarm the moment one goes down, and Syncthing, moving files between the Mac and the box without either touching a cloud drive.
Two more are next, not yet built. faster-whisper is CPU-only, same as the runner: int8 on the Ryzen, zero GPU contention.
ComfyUI, for local image generation, is the opposite case: it wants 4–8GB of VRAM, with well under half a gigabyte free once Ollama’s coder model is resident. It needs a two-mode toggle: unload the coder model, let ComfyUI take the full 12GB, generate, then reload the coder.
The runner earned the “do this first” slot precisely because it never had to ask that VRAM question. Everything after it does.
A machine that used to power on once a month for Windows Update now builds software for a living, for the cost of electricity it was already burning.