All posts
CI/CDCost OptimizationSelf-Hosted RunnersGitHub Actions

What CI Costs a 10-Developer Startup — and How to Cut It ~60%

What a typical 10-person startup spends on GitHub-hosted CI — and the self-hosted, multi-provider runner setup that runs the same builds for about 60% less.

The Infragnite Team 10 min read

Self-hosting your CI runners sounds like a big-company move. It isn’t — the math tips in your favor a lot earlier than people expect. To show where, let’s put real numbers on a team most readers will recognize: a 10-developer startup, sized from public engineering benchmarks rather than our own books.

Meet the team, by the numbers

A healthy team merges around 3–5 pull requests per developer per week — the 2025 median across 6.1M PRs is about 3.1, with top-quartile teams above 4.5.1 Call our ten devs four PRs each per week — about forty a week, ~160–170 PRs a month.

Every PR triggers CI when it opens and on each push (developers commit 3–5 times a day2), plus a build on every merge to main. That’s roughly four pipeline runs per PR — ~650–700 CI runs a month, about thirty on a working day.

How long does a run take? CircleCI’s 2025 data, across 28M+ workflows, shows a wide spread:3

Run typeDuration
Quick PR checks (lint, unit tests)~2–3 min
Full build + test (average team)~11 min
Heavy / integration / AI-triggered cascades (p95)25+ min

Those are wall-clock pipeline durations; GitHub meters per job, and most runs fan out into a couple of parallel jobs. Weight the mix — lots of short checks, a minority of full builds — and it lands near ~9–10 metered minutes per run. Across ~700 runs, the team meters roughly 13,000 CI minutes a month.

The bill

GitHub’s free tier covers 2,000–3,000 of those minutes (Free vs Team plan). Assuming the Team plan’s 3,000-minute allowance, that leaves ~10,000 billable minutes, charged per minute:

  • 2-core Linux ($0.008/min): the ~10,000 billable minutes run ~$80/month.
  • 4-core Linux ($0.016/min): real builds usually want 4 vCPU — more parallelism, less swapping on heavier test suites — so ~$160/month, about $1,900 a year. (GitHub’s 2-core default is where teams first feel the squeeze.)

That’s the number a growing 10-person team quietly starts paying — and it climbs as the team and the test suite grow. (CircleCI’s data shows average pipeline durations rising as AI generates more code and bigger test cascades.3)

It’s not just GitHub

GitHub-hosted isn’t unusually expensive — it’s typical. Nearly every hosted CI service bills for compute the same way — most clustered around a cent a Linux minute, a couple selling fixed job slots instead:4

ToolBilling modelLinux rate (2026)
GitHub Actionsper build-minute$0.008/min (2-core), $0.016 (4-core)
GitLab CI/CDcompute minutes$0.010/min
CircleCIcredits~$0.006/min (medium)
Bitbucket Pipelinesbuild minutes$0.010/min ($10 per 1,000)
Azure DevOpsparallel jobs+$40/mo per extra hosted job
Buildkiteper-seat + your compute$15/user/mo — you run the agents
Jenkinsopen-source$0 license + your infra

The numbers shift with plan tiers and free allowances, but the shape doesn’t: you pay per minute for someone else’s compute, with no idle discount. The two outliers are the telling ones. Buildkite charges a flat per-seat fee and has you bring your own compute; Jenkins is free and always has. Both already assume the model this whole post is about — you own the runners. The difference is migration cost: Buildkite gets you there only if you move your pipelines off Actions onto its agents and per-seat pricing, and Jenkins means adopting a separate CI system entirely. What we’re doing keeps your existing GitHub Actions workflows untouched and just changes where they run.

The first mistake (we made it too)

The instinct is to do it “properly”: a Kubernetes cluster running the Actions Runner Controller. It works on day one — and then runs 24/7 to serve jobs that only fire a few hours a day. You’ve swapped a per-minute bill for a flat one you pay around the clock, mostly for idle.

The lesson that shaped everything after: the most expensive runner is the one sitting idle. Don’t reach for an always-on cluster until your utilization justifies it. Start cheaper.

The pivot: ephemeral, on-demand runners

Stop keeping runners alive. Spin up a fresh VM when a job arrives, run the one job, destroy the VM. You pay only while a build is actually running, and nothing when the queue is empty — which, for a 10-dev team, is most of the night and weekend.

The mechanics, if you’re building it:

  • A small controller listens for GitHub’s workflow_job webhooks.
  • On a queued job it provisions a VM with a cloud-init script that installs the runner, registers it as ephemeral (--ephemeral, so it takes exactly one job), and starts it.
  • When the job finishes, the runner exits and the controller destroys the VM.

No idle fleet, no state to babysit. There’s a security bonus, too: every job runs in a brand-new, single-use VM that’s destroyed the moment it finishes. Even with ephemeral per-job pods, ARC’s containers share the host kernel — a kernel-level escape from a fork PR or a poisoned third-party action crosses into the node and whatever else runs there. A fresh VM per job doesn’t share a kernel at all. (You can harden ARC with gVisor or Kata, but that’s extra moving parts you get for free with a VM boundary.) For anything that runs untrusted code, that clean-room-per-job isolation is worth as much as the savings.

The one trade-off is cold-start latency on the first job after a quiet spell — which is what the next layer fixes.

Routing — and falling back — across four pools

The insight that does most of the work: most CI jobs don’t need a fresh cloud VM, and the ones that do shouldn’t fail just because one provider is full. So run a few pools, route each job by its labels to the cheapest pool that fits, and have each tier fall back to the next when it can’t take the job.

PoolHandlesWhere it runs
DockerPR checks, reviews, light jobsa box you already rent
FirecrackerBuilds, sub-second microVMsa box you already rent
HetznerBuild overflow (ARM)on-demand cloud
ScalewayLast-resort fallback (x86)on-demand cloud

Those quick 2–3 minute PR checks — the bulk of the ~700 runs — never touch the cloud; they run in Docker containers on a box you already pay for. Builds want the Firecracker pool. Light and heavy work never compete for the same runners.

The interesting part is what happens when a pool is full. Builds flow down a cascade:

  1. Firecracker first. MicroVMs boot in well under a second on the box you already pay for — cheapest and fastest. But the pool is bounded by memory, so a burst of concurrent builds can exhaust it.
  2. → Hetzner, when Firecracker is saturated. The controller provisions an on-demand ARM VM. Hetzner ARM capacity is genuinely scarce, so it tries several locations before giving up.
  3. → Scaleway, when Hetzner has nothing. A different provider, different zone, x86 instead of ARM — so a region-wide ARM shortage can’t block you. Pricier, used only as the last resort.

The result: a build keeps finding a runner instead of failing on the first full pool. Capacity problems at one tier spill silently to the next instead of turning into a red, stuck pipeline. For a small team with no one on call for CI, that resilience matters as much as the cost.

Even with just two tiers — a primary pool plus one fallback on a different provider — you get the headline benefit: a capacity limit becomes “the deploy waited a little longer,” not “the deploy failed.”

One gotcha worth knowing up front: mind how each provider bills. Hetzner rounds up to the whole hour; Scaleway has a 60-minute minimum. With one-shot VMs, a short job can buy a whole billed unit. GitHub-hosted, by contrast, bills purely by the minute — no coarse rounding — so this is one place self-hosting can quietly hand some of the savings back if you’re not careful. Keep short jobs on your own flat-rate box (no per-job rounding there either), or reuse a cloud VM across a few queued jobs.

Where this is going next

The cascade above is a fixed priority order. It works, but it isn’t optimal — it doesn’t know that a 2-vCPU test and a memory-hungry build have different needs, and it doesn’t react to live prices.

The next step is a price-aware scheduler: given each job’s actual CPU and memory requirements, and the live availability and price across every connected provider, place the build on the cheapest runner that can actually run it — continuously, per job. No hand-tuned ladder; just the lowest-cost capable runner, every time. It isn’t shipped yet, but it’s the direction — and the natural job for a control plane that already knows your providers, their prices, and their live capacity.

What it costs the startup

Put the two side by side for the same ~13,000 minutes a month:

Cost driverGitHub-hosted (4-core)Self-hosted setup
Quick PR checks (the majority)metered per minuteflat-rate box — ~free at the margin
Buildsmetered per minutemicroVMs on the same box
Overflowmetered per minuteon-demand VMs, idle-free
Idle nights & weekends$0
Roughly~$160/month~$65/month

A flat ~$45/month dedicated box (a Hetzner AX-class machine or similar) absorbs the quick checks and most builds; cheap on-demand VMs (Hetzner ARM is ~€0.0128/hour, billed only while building) handle the overflow. That’s about 60% less than GitHub-hosted for the same workload — roughly $1,100 a year saved — and builds run in the same minutes, so there’s no speed tax.

Be straight about it: it’s ~60%, not the 90% you’ll see in louder posts, once you count the whole bill — the fixed box, the cloud overflow, the per-VM rounding. And it’s real operational work — which raises a fair question at this size: is roughly a thousand dollars a year even worth the trouble?

When it’s actually worth it

Honestly? At ten developers, no — not for the money. Saving ~$1,100 a year is a rounding error against the time you’d spend running a controller. If you’re a small team, do this for the other reasons: the clean-room VM isolation for untrusted code, never stalling on someone else’s runner capacity, no vendor lock-in, and full visibility into where CI spend goes. The cash is a bonus, not the case.

What flips the money argument is scale. CI minutes grow with the team, GitHub bills them linearly, and the ~60% gap holds the whole way up — so the absolute savings climb fast:

Team sizeGitHub-hosted / yrSelf-hosted / yrSaved / yr
10 devs~$1,900~$800~$1,100
50 devs~$11,900~$4,800~$7,100
200 devs~$49,000~$19,000~$30,000
1,000 devs~$249,000~$100,000~$149,000

Somewhere around fifty engineers the savings alone start to justify the effort; by a few hundred you’re funding a real slice of an infra engineer’s salary; at a thousand the runner bill is a line item finance asks about. The setup is identical at every size — only the number of zeros on the savings changes. The point of the 10-dev numbers isn’t that they’re huge. It’s that the per-minute economics that produce them don’t get worse as you grow — they get more worth acting on.5

A rough recipe

  1. Stay on the free tier while you fit it. Don’t build any of this until you’ve outgrown it.
  2. Put your steady, light jobs on a box you already own. Biggest lever, lowest effort.
  3. Add on-demand cloud VMs for overflow — ephemeral, one job each, destroyed after.
  4. Add a second provider as fallback so capacity limits never block you.
  5. Watch the billing model — whole-hour vs per-minute — and reuse VMs for short jobs.

None of it is exotic: a webhook listener, a cloud-init script, and the discipline to route each job to the cheapest thing that can run it.

If you’d rather not wire it all by hand

That recipe is a real amount of plumbing to build and keep healthy. Infragnite is an infrastructure control plane — we built it to manage all your infrastructure from one place, and on-demand Actions runners turn out to be one of the handier things it does. It provisions and tears down runners across Hetzner, Scaleway, DigitalOcean and more — right alongside your servers, DNS, monitoring and deployments — through each provider’s native API, with the cost and queue-time dashboards that make the numbers above visible. No agents, no custom runtime, no lock-in: stop using it and your runners keep running.

If that sounds useful, you can try it here. And if you’d rather build it yourself — genuinely, go for it; the recipe above is the whole thing.

Footnotes

  1. LinearB, 2025 Engineering Benchmarks (analysis of 6.1M+ pull requests): median ~3.1 merged PRs per developer per week, top quartile 4.5+.

  2. We assume developers push several times a day — a commonly cited range is 3–5 commits per developer per day.

  3. CircleCI, State of Software Delivery (28M+ CI workflows): high-performer median pipeline duration ~2m43s, average ~11 min, 95th percentile 25+ min, with durations trending upward as AI-generated changes trigger larger test cascades. GitHub-hosted prices are 2025–2026 list rates ($0.008/min for 2-core Linux, $0.016/min for 4-core), net of GitHub’s free allowance. 2

  4. 2026 list rates for Linux compute, which vary by plan tier and free allowance: GitLab CI/CD $0.010/min (no OS multiplier); CircleCI ~$0.006/min on a medium Linux/Docker class (10 credits/min at $0.0006/credit); Bitbucket Pipelines $10 per 1,000 build-minutes; Azure DevOps one free Microsoft-hosted parallel job (1,800 min/mo) then +$40/mo per additional hosted parallel job; Buildkite $15/user/mo with bring-your-own-compute (you run and pay for the agents); Jenkins is open-source (no licence cost, you provide the infrastructure).

  5. Scaling figures extrapolate the 10-developer model linearly (CI minutes scale with team size) at 4-core GitHub-hosted list prices, net of the free tier, with self-hosted held at the same ~60% reduction. Real numbers depend on your build mix and how well a fixed server amortizes — larger teams tend to do better than 60% as the fixed box spreads over more work.

Let your infrastructure handle your runners.

On-demand runners, cost visibility, and queue monitoring across providers — from one dashboard, with minimal configuration.

Start using Infragnite today