There's a rite of passage every growing engineering team goes through: someone opens the GitHub Actions billing page, makes a noise somewhere between a gasp and a sob, and says "why are we paying $1,400 a month to run npm test?" Then someone else says "let's just self-host the runners" like it's a free lunch. It is not a free lunch. It's more like agreeing to cook the lunch yourself, forever, including the dishes.
I've been on both sides of this decision โ the "just self-host it" enthusiast and the person six months later maintaining a fleet of runner VMs that somehow became load-bearing infrastructure nobody remembers approving. Let's talk about when self-hosted runners actually pay off, and when they're a very expensive way to feel thrifty.
The Math Nobody Does First
GitHub-hosted runners bill per minute, and the multiplier depends on the machine size โ a 2-core Linux runner is 1x, but bump up to 4-core or add Windows/macOS and the multiplier climbs fast. macOS runners in particular are priced like they're powered by artisanal electricity. If your team is mostly running npm test on 2-core Linux boxes, minute-based pricing is genuinely cheap and you should stop reading and go do something more useful with your afternoon.
The economics flip when you have one (or more) of these:
- High volume. Hundreds of PRs a day across a monorepo, each triggering a full matrix build.
- Big machines. GPU workloads, large Docker builds, anything needing 16+ cores or high memory that GitHub's largest hosted tier prices at a premium.
- Network-locked resources. Your CI needs to reach an internal database, an on-prem artifact cache, or a VPC-only service, and paying for GitHub-hosted-to-VPN tunneling is its own tax.
- Persistent caches that actually persist. Hosted runners are ephemeral by design โ every cache miss on a cold Docker layer costs you real minutes, every single run.
At Cubet, we did this calculation properly before switching anything โ pulled six months of Actions billing, bucketed minutes by workflow, and found that 80% of our spend came from exactly two jobs: a monorepo's full integration suite and a set of container builds for a data pipeline that pulled a 4GB base image on every single run. Everything else was noise. That's the actual lesson: don't self-host the whole fleet to fix two workflows. Self-host the two workflows.
Ephemeral or Bust
The single biggest mistake teams make with self-hosted runners is treating them like pets โ one long-lived VM, registered once, running job after job after job. That's how you end up with a runner that has three years of node_modules debris, a stale Docker daemon, and โ this is the part that should scare you โ full access to whatever secrets got injected into any job that ever ran on it. A self-hosted runner on a public repo without hardening is an open invitation for someone to fork your repo, open a PR, and get their code executed on your infrastructure via pull_request_target.
The fix is to make runners disposable. Spin one up per job, run it once, tear it down:
# .github/workflows/build.yml
jobs:
build:
runs-on: [self-hosted, linux, ephemeral, x64]
steps:
- uses: actions/checkout@v4
- run: docker build -t app:${{ github.sha }} .
# runner bootstrap, invoked by autoscaler on demand
./config.sh --url "$GH_URL" --token "$RUNNER_TOKEN" \
--ephemeral --unattended --replace
./run.sh
# process exits after one job; VM/container is destroyed by the caller
Tools like Actions Runner Controller (ARC) on Kubernetes handle this for you โ it scales runner pods up on webhook events and tears them down after each job, so you're not paying for idle capacity and you're not accumulating state between builds. If you're not on Kubernetes, a simple autoscaling group with a cloud-init script that registers, runs one job, then terminates does the same job with less machinery.
The Bill You Don't See Coming
Self-hosting doesn't remove cost, it converts it. You stop paying GitHub per minute and start paying for compute, storage, networking, and โ the expensive one โ your own time keeping the fleet patched, scaled, and secure. Nobody puts "engineer-hours babysitting runner AMIs" on the original spreadsheet, and it's always bigger than expected. If you don't already have platform capacity to own this, the "savings" are a mirage that shows up as an on-call page instead of a line item.
The honest rule of thumb we landed on: self-host if you have workloads that are either too big, too frequent, or too network-privileged for hosted runners to handle economically or securely โ and only after you've measured which workloads those actually are. Don't self-host because minute-pricing feels bad in the abstract. Measure first, then cut the two jobs that are actually expensive, keep everything else on hosted runners, and make whatever you do self-host ephemeral from day one.
Go pull up your Actions billing dashboard and sort by minutes-per-workflow before you provision a single VM. I promise the two-job version of this story is way less painful than the whole-fleet one.