Runner infrastructure and CI cost at scale
The CI bill is only half the cost: queue time multiplied by engineer-hours is the other half. Run ephemeral runners (clean room per job), autoscale to the queue, put them on spot to cut the bill 60-90%, right-size, and watch p95 queue time, not just job duration.
The mandate came down after a flaky third-party runner leaked a token: “move everything to large hosted runners, to be safe.” It worked. It also did two things nobody modeled. The monthly CI bill went up 10x — the biggest machine on every job, even the ones that lint a README. And because finance then capped concurrency to claw that bill back, PRs started queueing 20+ minutes at peak. The dashboard everyone watched showed job duration holding steady, so it looked fine. What it didn’t show was queue time: forty engineers each waiting twenty minutes for a free runner, several times a day. The runner bill they were fighting over was a rounding error next to the engineer-hours they were burning in the queue. This lesson is about the runner fleet underneath your pipelines — hosted vs self-hosted, ephemeral vs persistent, autoscaling, spot, and the cost curve that has two axes, not one.
Hosted vs self-hosted: who owns the machine
Before diving in: the question isn’t just “which runners are cheapest per minute.” When you factor in engineer-wait, the answer often flips completely — which is why this lesson starts with the cost model, not the feature comparison.
GitHub-hosted runners give you a clean, ephemeral VM per job with zero operations on your side — you pay per minute and never patch anything. That is the right default until one of three things bites: the bill at high volume, the spec ceiling (you need big machines, GPUs, or specific hardware the hosted catalog doesn’t offer), or network reach (the job must touch services inside your VPC). Self-hosted runners answer all three — your machines, cheaper at high volume, any spec, VPC access — but you inherit patching, scaling, and security.
That security clause is not a footnote. A self-hosted runner attached to a public repository is dangerous: a pull request from a stranger runs their code on your infrastructure. Keep self-hosted runners off public repos, or gate them behind required approval for first-time contributors.
# A job pinned to your self-hosted fleet by label.
jobs:
build:
runs-on: [self-hosted, linux, x64, ephemeral] # labels select the fleet
steps:
- uses: actions/checkout@v4
- run: ./ci/build.shEphemeral runners: a clean room per job
This is the practice that makes self-hosted safe. An ephemeral runner executes exactly one job and is then destroyed and recreated fresh. A persistent runner stays up and takes job after job — and accumulates state between them: leftover files in the workspace, a poisoned dependency cache, a background process spawned by a previous (possibly malicious) job. That residue is a contamination channel. A later job can read secrets left behind, hit a tampered binary, or inherit a corrupted cache — and nothing is reproducible because every job starts from wherever the last one left the box.
Ephemeral runners close all of that. Each job gets a clean room: deterministic starting state, no cross-job leakage, and a compromised job dies with its runner instead of lying in wait for the next one. The cost is spin-up time per job — you pay a fresh boot every time — which you mitigate with warm pools (a few idle runners pre-provisioned) and fast base images. The isolation is not obtainable any other way; the spin-up cost is.
# Register a self-hosted runner that exits after one job and is not re-used.
./config.sh --url https://github.com/acme/payments \
--token "$REG_TOKEN" \
--labels linux,x64 \
--ephemeral # <- destroy after a single job; no state survives
./run.sh▸Why this works
Why are ephemeral runners worth the per-job spin-up cost when a fast persistent runner skips it? Because a persistent runner carries state across jobs — a cached file, a leftover process, a poisoned dependency from a previous and possibly untrusted job — so jobs are neither reproducible nor isolated, and one compromised job can tamper with every job that lands on that box afterward. Ephemeral gives a clean room per job: deterministic state, no cross-job leakage, and a compromised job dies with its runner instead of persisting. The spin-up cost is mitigated with warm pools and fast images; the isolation simply is not available from a persistent fleet at any price. You are buying a security and reproducibility property, not a performance one.
Autoscaling: track the queue, not a guess
A fixed fleet of N runners has two failure modes, and you can’t tune your way out of both with a constant. Too few runners and jobs queue — developers wait, and the real cost is engineer idle time, not an idle machine. Too many and you pay for idle runners sitting warm with nothing to do. Whatever N you pick is wrong at some point in the day, because demand is spiky: quiet at 3am, a thundering herd when everyone pushes before standup.
Autoscaling makes N follow demand. Actions Runner Controller (ARC) runs ephemeral runners as Kubernetes pods that scale with the job queue: when the queue grows, it spins up more runner pods; when it drains, it scales to zero so idle hours cost nothing. You get the best of both — capacity when there’s a herd, no bill when there isn’t — and crucially the runners are still ephemeral, so autoscaling and clean-room isolation compose instead of fighting.
# ARC: an autoscaling set of ephemeral runners (scales with queue, down to zero).
apiVersion: actions.github.com/v1alpha1
kind: AutoscalingRunnerSet
spec:
githubConfigUrl: https://github.com/acme/payments
minRunners: 0 # scale to zero when the queue is empty
maxRunners: 200 # ceiling so a herd can't bankrupt you
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latestThe cost curve has two axes
Here is the senior reframe the war-story team missed. Ask yourself: when your team was debating the runner bill, was anyone measuring how many engineer-hours were queued up waiting? CI cost is not one number. It is the runner $ plus the engineer-time $ of everyone waiting on CI. Optimize only the bill — fewer runners, smaller machines, capped concurrency — and you inflate queue time and wall-clock, which multiplies across every engineer waiting. An organization’s engineer-hours dwarf its CI bill, so the true optimum is usually more parallelism, not less: add runners until wall-clock stops improving, because past that point you’re paying for machines that don’t shorten the wait, but up to that point every minute shaved is multiplied by the team.
Two levers make “more parallelism” cheap. First, spot / preemptible instances: CI is fungible and retry-safe — a preempted job just re-runs — so it’s the ideal workload to put on spot, cutting the runner bill 60-90%. Second, right-sizing: most jobs need a small runner; reserve the big machines for the few jobs that actually get faster on them. A README lint on a 64-core runner is pure waste.
Your CI bill is high AND PRs queue 20 minutes at peak. Engineers are idle in the queue several times a day. What fleet design fixes both at once?
You're choosing between GitHub-hosted and self-hosted runners. Which statement captures the core trade you're actually making?
Why does an autoscaling fleet of ephemeral runners beat a fixed fleet of persistent runners for a busy org with spiky demand?
- 01Explain why ephemeral runners are worth the per-job spin-up cost over a fast persistent fleet, and how autoscaling (ARC) composes with them.
- 02Lay out the two-axis CI cost model and the fleet decisions that follow: hosted vs self-hosted, spot, right-sizing, and the metric to watch.
The runner fleet under your pipelines decides both the CI bill and the hidden cost of engineer-wait. GitHub-hosted runners are the zero-ops default — a clean ephemeral VM per job, pay-per-minute — until the bill at high volume, the spec ceiling (big machines, GPUs), or VPC reach forces you to self-hosted, where you gain cost and spec control but own patching, scaling, and security, and must never attach self-hosted runners to public repos because a stranger’s PR would run on your infrastructure. The practice that makes self-hosting safe is ephemeral runners: each runner executes exactly one job then is destroyed, giving a clean room per job, while a persistent runner accumulates cross-job state (leftover files, a poisoned cache, a stray process) that leaks between jobs and is non-reproducible; the per-job spin-up cost is mitigated with warm pools and fast images, but the isolation is not obtainable otherwise. A fixed fleet fails both ways — too few runners queue and waste engineer time, too many pay for idle — so autoscaling via Actions Runner Controller runs ephemeral runners as Kubernetes pods that scale with the queue and to zero when idle. Finally, CI cost has two axes: the runner bill plus engineer-wait, and because engineer-hours dwarf the bill the optimum is usually more parallelism, not less — put the retry-safe CI workload on spot/preemptible instances to cut the bill 60-90%, right-size so big machines serve only the jobs that benefit, and watch p95 queue time rather than job duration, since queue time is invisible in the run log yet directly multiplies developer wait. Now when you hear engineers say “CI is slow,” your first question is not how long jobs take — it’s what p95 queue time looks like.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.