Guides / Computing costs and local AI
When trimming CI was not enough: moving the jobs to a desktop we already owned
Why more pipeline trims could not reach our GitHub Actions target, four paths compared on cash, and the two-hour setup of a self-hosted runner on a desktop.
Engineering note
On October 31, 2026, six days after our first GitHub Actions audit, the bill was still climbing. The first round of changes (a container image instead of a browser install in every job, grouped smoke jobs, one deploy job instead of a sixteen-way matrix) worked as measured, and the spending cap still had to be raised three more times, to $260. We had planned to re-check on November 1. We did not wait.
This guide covers the arithmetic that ruled out more trimming, four options compared, the self-hosted runner we chose, how fast it is, and what the first evening taught us.
Why trimming alone could not get there
The target was $50 a month for everything GitHub. The plan costs $4 and includes 3,000 Linux runner minutes; at $0.006 a minute, $46 of overage buys about 7,700 more, so the budget is roughly 10,700 billed minutes. October was about 35,000: 1,274 runs of the main CI workflow at 59 to 73 billed minutes for a full run.
The cut needed was 70 percent. Every remaining trim we could list came to 50 or 60 percent together, and each traded away a check that had caught something. The rest was activity: forty runs a day from two coding agents. Trimming the pipeline does not change how often it runs.
Four paths, compared on cash
These were planning estimates at our October 31 usage and prices, not observed monthly bills after the move. The desktop estimate includes its electricity; the cloud option adds a rented instance.
| Path | Monthly cash | What it costs that is not money |
|---|---|---|
| Keep trimming the pipeline | $80 to $100 | A cross-package break is caught at merge or overnight instead of on the pull request |
| Self-hosted runner on a desktop we own | About $10 | The desktop stays on; two hours of setup; the repository must stay private |
| Self-hosted runner on a rented cloud instance | About $89 | Burstable CPU that can throttle; the money moves to another bill |
| Everything on premises, own Git server | About $6 | The coding agents, review bots, dependency updates and deploy trust all stop working |
At the October 31 setup, GitHub charged nothing per minute for self-hosted runners. It had announced a $0.002 per minute charge for them in private repositories, then postponed it without a date; at our volume that would be about $68 a month. Check current pricing before relying on the estimates here.
The desktop won: 20 cores and 32 GB, already our local language-model host. The 2017 iMac we had revived under Linux is too slow for the heavy jobs, but it later found a role.
Worked example: one month under each path
Take October's 35,000 billed minutes. Hosted, that is 32,000 over the 3,000 included: $192 of overage, about $196 with the plan. Move the CI and nightly jobs to the desktop and keep the deploy and monitoring workflows hosted, since they hold cloud deploy roles the desktop must never have. Those used about 2,100 minutes, inside the 3,000 included: $4 in all. Even the two tiny framing jobs moved: GitHub rounds each job up to a minute, so 1,274 runs would have billed 2,500 minutes for twelve seconds of work each. The rented instance adds $84 to the $4. The desktop's electricity is $2 to $5.
A one-line change with a way back
Every job now reads a repository variable: self sends it to the self-hosted runner, anything else to GitHub's hosted runner as before.
runs-on: ${{ vars.CI_RUNNER == 'self' && 'self-hosted' || 'ubuntu-latest' }}That variable is the whole fallback: when the desktop is off, one setting changed from a phone makes the next run hosted. Job names did not change, so branch protection was untouched. The runner needs no public address or open port; it connects out to GitHub and waits for jobs, so the owner can be anywhere. It does need to stay awake.
Setting it up on a Windows desktop
The runner is Linux software, so on Windows it runs inside the Windows Subsystem for Linux (WSL). In order:
- Sleep and hibernate off; Windows Update restarts at night.
- Ubuntu 24.04 under WSL 2 with systemd on, and a Linux user for the runner.
- Docker Engine inside Ubuntu, and the browser-test image pulled once.
- Node and the browser's system libraries for the one job that runs outside a container.
- Runners registered as system services: three at first, four once WSL's memory limit went from 15 to 24 GB.
- A startup task that keeps WSL running. It needs the account's stored password; saved without one, WSL never started.
- Segmentation offload off on WSL's network card, or uploads crawl (below).
Setup took about two hours of the owner's time.
Speed: where the desktop wins and where it loses
The same nightly job, which lints, typechecks, runs the whole suite twice and smoke-tests every app:
| Step | Hosted, 4 vCPU | Desktop, first try | Desktop, matched |
|---|---|---|---|
| Typecheck | 4 min 52 s | 57 s | 1 min 4 s |
| Unit, integration, regression | 11 min 26 s | 2 min 48 s | 3 min 46 s |
| Coverage | 15 min 13 s | 1 min 53 s | 3 min 50 s |
| Build and smoke every app | 31 min 45 s | 9 min 43 s | 14 min 14 s |
| Upload test reports | 8 s | 5 min 28 s | 7 min 50 s |
| Whole job | 65 min | 22 min | 32 min |
Processor work is two to five times faster. The first try was fastest, and wrong: tools size themselves to the machine, so on 20 cores the browser tests ran eight to ten at once where hosted runs two, and five tests failed that pass hosted. Capping workers at the hosted counts ("matched") fixed them. The first afternoon found four more differences: root-owned files left by container jobs, the clock in local time instead of UTC, a click-timing race the faster machine exposed, and the memory limit.
Uploads were the slow line, and we blamed the office connection. We were wrong.
The first evening: uploads, labels and a watchdog
The uploads were WSL, not the line. A 25 MB test upload went at 4.6 MB a second from Windows on the desktop, 7.5 MB a second from the iMac on the same Wi-Fi, and 73 KB a second from inside WSL. The cause was segmentation offload on WSL's virtual network card; with it off, the upload did 2.2 to 3 MB a second. One line in /etc/wsl.conf keeps it off after every restart:
[boot]
systemd=true
command="/usr/sbin/ethtool -K eth0 tso off gso off"The fix suggested first, WSL's mirrored networking mode, broke outbound connections on our Windows 11 build and took every runner offline until we removed it.
Routing by label. GitHub has no load balancer between machines: an idle runner takes the first queued job whose labels it has. The desktop's four runners carry a dell label that the heavy jobs ask for; the few-second framing jobs ask for any runner.
The iMac became a watchdog. We first made the iMac a runner for those framing jobs; the other agent's review caught that two of them needed Node the iMac did not have, so each job now installs its own. Then we gave the iMac a better job. It runs none. Once a minute it checks the desktop's runners and flips CI_RUNNER to hosted when they have all been offline for 10 minutes, or all busy for 7 with a job waiting, and back when one is free. Each switch costs cents.
Parallel load is the limit, not the machine. One pull request's run took 11.5 minutes on the desktop against 18 hosted. With four runs at once, 44 jobs taking turns on four runners, it took 21 to 26 minutes, and a three-second job waited up to 15 minutes. Hosted gives every job its own machine. On a quiet day the desktop wins; on a busy one the watchdog sends the overflow back to GitHub.
What we measure next, and what we gave up
Our October 31 plan was to leave the spending cap at $260 for a full week, then lower it to $50 as a guard rail. The usage export after that week would test the projected savings: hosted minutes should come from deploys, monitoring and the watchdog's overflow. The first-evening timings above do not establish a monthly saving.
Three things are true now. The pipeline depends on a computer in an office staying on, which the variable and the watchdog soften but do not remove. Any pull request runs code on that computer, acceptable only because the repository is private and only the owner, two agents and a dependency bot push to it. And 1,274 runs a month for a one-person company is still the number to beat; a postponed fee is not a cancelled one.
Sources
- GitHub Docs. Self-hosted runners: what they are, who pays for the machine, and the guidance against using them with public repositories. docs.github.com/en/actions/concepts/runners/self-hosted-runners
- GitHub Docs. Configuring the self-hosted runner application as a service. docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application
- GitHub Docs. About billing for GitHub Actions: included minutes per plan, per-minute rates and per-job rounding. docs.github.com/en/billing/concepts/product-billing/github-actions
- GitHub Changelog, 2025-12-16. Coming soon: simpler pricing and a better experience for GitHub Actions (the hosted-runner price cuts and the postponed self-hosted charge). github.blog/changelog/2025-12-16-coming-soon-simpler-pricing-and-a-better-experience-for-github-actions/
- Microsoft Learn. Use systemd to manage Linux services with WSL. learn.microsoft.com/en-us/windows/wsl/systemd
- Docker Docs. Install Docker Engine on Ubuntu. docs.docker.com/engine/install/ubuntu/
- Playwright. Docker: the official image with browsers and their system dependencies. playwright.dev/docs/docker
- Amazon Lightsail. Instance bundles (the general-purpose sizes and prices compared in the table). docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-bundles.html