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.

PathMonthly cashWhat it costs that is not money
Keep trimming the pipeline$80 to $100A cross-package break is caught at merge or overnight instead of on the pull request
Self-hosted runner on a desktop we ownAbout $10The desktop stays on; two hours of setup; the repository must stay private
Self-hosted runner on a rented cloud instanceAbout $89Burstable CPU that can throttle; the money moves to another bill
Everything on premises, own Git serverAbout $6The 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:

  1. Sleep and hibernate off; Windows Update restarts at night.
  2. Ubuntu 24.04 under WSL 2 with systemd on, and a Linux user for the runner.
  3. Docker Engine inside Ubuntu, and the browser-test image pulled once.
  4. Node and the browser's system libraries for the one job that runs outside a container.
  5. Runners registered as system services: three at first, four once WSL's memory limit went from 15 to 24 GB.
  6. A startup task that keeps WSL running. It needs the account's stored password; saved without one, WSL never started.
  7. 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:

StepHosted, 4 vCPUDesktop, first tryDesktop, matched
Typecheck4 min 52 s57 s1 min 4 s
Unit, integration, regression11 min 26 s2 min 48 s3 min 46 s
Coverage15 min 13 s1 min 53 s3 min 50 s
Build and smoke every app31 min 45 s9 min 43 s14 min 14 s
Upload test reports8 s5 min 28 s7 min 50 s
Whole job65 min22 min32 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

Read next

All in Computing costs and local AI · Browse all topics