Where CI minutes cost comes from on GitHub-hosted runners
GitHub-hosted runners are fresh virtual machines that GitHub provisions for each job and discards afterwards. Teams use them because they need no capacity planning, patching or isolation work. You pay only for what jobs consume.
That consumption is billed through several separate meters:
| Bill component | What drives it | Why it surprises teams |
|---|---|---|
| Billed minutes | Job runtime, rounded up per job | Many short jobs round up many times |
| OS multiplier | Windows and macOS cost more than Linux | A single macOS job can outweigh many Linux jobs |
| Larger runners | More CPU and RAM at a higher per-minute rate | Usually not covered by included plan minutes |
| Storage | Artifacts and caches retained over time | Grows quietly with long retention settings |
Rates and included quotas change. Check GitHub's own pricing page rather than any figure quoted in an article.
Illustrative estimate (assumed numbers, not a benchmark): A workflow fans out into 12 Linux jobs, each running 70 seconds.
- Actual time: 12 × 70 s = 14 minutes
- Billed time: 12 × ceil(70 / 60) = 12 × 2 = 24 minutes
In this illustrative example, with the assumed 70-second job duration, billed time comes out about 70% higher than the compute actually used. This is arithmetic from assumed inputs, not a measured result. Your own ratio depends on how your job durations fall relative to minute boundaries. The general formula is:
billed minutes = Σ ceil(job_seconds / 60) × OS_multiplier
Per-minute price on its own says little about what CI costs you. Failed runs, retries, flaky tests and slow jobs all consume minutes without shipping anything. A more useful unit is cost per successful build:
total CI spend ÷ builds that passed
This metric is the basis for comparing hosted and self-hosted options.
Total cost of ownership for self-hosted runners: a worked example
The same metric applies to self-hosted runners, but the numerator now includes everything you operate, not only compute.
Monthly cost per successful build = (compute × (1 + rerun rate) + disks + egress/NAT + registry pulls + observability + engineer hours × hourly rate) ÷ successful builds
Illustrative estimate (assumed numbers, not a benchmark). Assume four always-on build nodes (730 h/month each) at $0.20/h on-demand, a spot price 65% lower, an 8% rerun rate on spot caused by interruptions, 200 GB of disk per node at $0.10/GB-month, 10 engineer-hours per month at $100/h, and 6,000 successful builds per month.
| Line item (monthly) | On-demand | Spot |
|---|---|---|
| Compute | $584 | $204 + 8% reruns = $221 |
| Persistent disks | $80 | $80 |
| Egress and NAT (assumed) | $60 | $60 |
| Registry pulls (assumed) | $30 | $30 |
| Observability (assumed) | $50 | $50 |
| Engineer hours (upgrades, patching, autoscaling, on-call) | $1,000 | $1,000 |
| Total | $1,804 | $1,441 |
| Per successful build | $0.30 | $0.24 |
Spot pricing cuts the compute line by about 60%, but it reduces the total by only 20%. Interrupted jobs rerun from the start, so a higher interruption rate or longer builds shrink the savings further. The larger lesson is that labor makes up most of the total in this example. A small team running few builds pays nearly the same fixed operational cost as a larger team, and that cost can cancel out any compute savings.
Engineer-hour rates, utilization, and cloud pricing vary widely. Replace every input above with your own figures before you draw conclusions.
Finding your break-even point: utilization, burstiness, and queue time
Break-even depends less on total minutes than on how those minutes are spread across the day. Pull four inputs from your workflow run history: monthly build minutes (M), average job duration, peak concurrent jobs (P), and an hourly histogram of job start times.
A fixed pool has to be sized for P, but it is billed around the clock. Effective utilization is U = M ÷ (P × 43,200), where 43,200 is the number of minutes in a 30-day month. The calculation, spreadsheet-style:
- Hosted cost = M × hosted_rate
- Self-hosted cost = P × node_cost + ops_cost + Q
- Queue cost Q = delayed_jobs × avg_wait_hours × engineer_rate
Count Q only when a person is actually blocked, such as a pull request waiting for checks. A nightly job sitting in a queue usually costs nothing.
Illustrative estimate (assumed numbers, not a benchmark):
| Assumed input | Value |
|---|---|
| Hosted rate | $0.008/min |
| Node cost | $150/month per node |
| Operations cost | $1,500/month |
| Peak concurrency (P) | 20 |
- Bursty team, M = 60,000. U is about 7%. Hosted costs $480 and self-hosted costs $4,500. A team whose builds cluster in working hours and stop overnight should stay on hosted runners or use a pool that scales to zero.
- Steady pipeline, M = 600,000. U is about 69%. Hosted costs $4,800 and self-hosted costs $4,500, which is roughly break-even. Higher volume moves the result further toward self-hosting.
- Undersized pool, P = 12, M = 600,000. Self-hosted cost drops to $3,300. If 2,000 jobs wait 10 minutes each at $100/hour, Q is about $33,000. That is far more than the money saved on nodes.
Autoscaling reduces idle capacity, but it reacts with a delay. Node boot time and image pulls add latency, and that latency belongs in Q. Autoscaling also adds a controller, cloud API quotas, and scaling policies, and any of these can fail during the busiest part of the day.
MINUTES_PER_MONTH = 43_200 # 30-day month, billed around the clock
def utilization(m, p):
# Fraction of paid pool capacity that actually runs jobs
return m / (p * MINUTES_PER_MONTH)
def hosted_cost(m, hosted_rate=0.008):
return m * hosted_rate
def queue_cost(delayed_jobs, avg_wait_min, engineer_rate=100):
# Count only waits that block a person, e.g. a PR waiting for checks
return delayed_jobs * (avg_wait_min / 60) * engineer_rate
def self_hosted_cost(p, node_cost=150, ops_cost=1_500, q=0.0):
return p * node_cost + ops_cost + q
scenarios = [
# (label, monthly minutes M, pool size P, blocked jobs, avg wait in minutes)
("bursty", 60_000, 20, 0, 0),
("steady", 600_000, 20, 0, 0),
("undersized", 600_000, 12, 2_000, 10),
]
for label, m, p, jobs, wait in scenarios:
q = queue_cost(jobs, wait)
hosted = hosted_cost(m)
self_hosted = self_hosted_cost(p, q=q)
print(f"{label:<11} U={utilization(m, p):5.1%} "
f"hosted=${hosted:>8,.0f} self-hosted=${self_hosted:>8,.0f} "
f"(queue ${q:,.0f})")
break_even_m = self_hosted_cost(20) / 0.008
print(f"break-even at P=20: {break_even_m:,.0f} build minutes/month")
A small Python calculation that reproduces the section's illustrative estimate. It computes utilization, hosted cost, and fixed-pool cost including the cost of blocked engineers, then shows the monthly build minutes at which a 20-node pool costs the same as hosted runners.
Build speed as a cost lever: caches, ephemeral runners, and startup latency
The price per minute is only half the bill. The other half is how many minutes each job runs. A self-hosted machine that persists between jobs keeps the dependency cache, Docker layer cache, and compiler cache on local disk. The next job skips the downloads and full rebuilds and compiles only what changed. That cuts compute spend and the time developers spend waiting on a red or green check.
Persistent state is also a liability. A long-lived runner collects stray files, drifting toolchain versions, and leftover tokens that the next job can read, and that job may come from a different repository. Make runners ephemeral by default: each runner takes one job and is then destroyed. Every job starts clean, and nothing carries over.
The trade-off is that ephemeral runners lose their caches when they are destroyed. Each job also pays for pod or VM startup and an image pull. You can recover most of this cost:
- Pre-pull base images onto nodes.
- Keep a warm node pool sized above zero.
- Store caches outside the runner, for example in object storage, a registry-backed layer cache, or a remote compiler cache.
Illustrative estimate (assumed numbers, not a benchmark): cost per job = (startup min + build min) × rate. Assume a rate of $0.01/min.
| Runner type | Cost per job | Speed | Security risk |
|---|---|---|---|
| Long-lived, warm local cache | (0 + 6) × 0.01 = $0.06 | Fastest | High: state and secrets persist |
| Ephemeral, cold | (2 + 14) × 0.01 = $0.16 | Slowest | Low |
| Ephemeral, pre-pulled + external cache | (0.5 + 7) × 0.01 = $0.075 | Close to warm | Low |
How Actions Runner Controller works and what you still own
Actions Runner Controller (ARC) is a Kubernetes operator that runs GitHub Actions runners as pods. A controller watches GitHub for queued jobs that match your runner pool. It creates runner pods to meet that demand and deletes them when the queue drains. In ephemeral mode each pod registers, takes exactly one job, and exits. That gives you the "ephemeral" rows from the table above: no state or secrets leak between builds, at the price of paying startup latency on every job.
The flow, end to end:
GitHub job queue → ARC controller → runner pods (one job each) → pending pods → node autoscaler → new nodes → pods scheduled
The last hop is the one that sets your worst-case queue time. If no node has room, pods sit in Pending until the cluster autoscaler provisions capacity. That wait can exceed the build itself.
What you still own
- The cluster: upgrades, networking, and the security boundary between untrusted workflow code and everything else running there.
- Node autoscaling: instance types, scale-up speed, scale-down delay, and minimum warm capacity.
- Runner image builds: toolchains, patching, and keeping images small enough to pull quickly.
- Capacity limits: maximum runners per pool, quotas, and budget ceilings.
ARC also concentrates risk. Every pipeline now depends on one cluster. A control-plane outage, a bad autoscaler change, or container registry rate limits during a burst of image pulls can stop all builds at once. GitHub-hosted runners rarely fail in that correlated way.
Workflows target the pool with runs-on, set to the label or name the pool registers under. The example below shows this.
name: build
on:
push:
branches: [main]
pull_request:
jobs:
build:
# Target the self-hosted pool by the name it registers under.
# With ARC scale sets this is the runner scale set name;
# "arc-linux-x64" is a placeholder for your own pool name.
runs-on: arc-linux-x64
# Cap how long a job can run, so a stuck build does not hold
# a pod (and its node) indefinitely.
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# The runner image must already contain the toolchain,
# or each ephemeral pod pays the install cost on every job.
- name: Build
run: make build
- name: Test
run: make test
A minimal workflow that sends its job to an ARC-managed self-hosted pool by setting runs-on to the pool's registered name, with a timeout so a stuck job cannot hold capacity indefinitely.
A hybrid routing pattern, when to avoid self-hosting, and alternatives
Route by workload, not by repository. Heavy, frequent backend jobs, such as compiles, integration tests against real databases, and image builds, target self-hosted labels like self-hosted, linux, x64, build-large. Release tagging, docs builds, scheduled housekeeping and anything triggered by outside contributors stay on GitHub-hosted runners. At low volume those jobs cost little per minute, and each one gets a fresh VM.
Build the fallback in from the start so you never have to edit workflows during an outage. In the example that follows, runs-on is read from a repository variable, and a workflow_dispatch input can override it. When the fleet degrades, changing that one variable moves jobs back to hosted runners without a pull request. This only works if jobs stay portable, so don't depend on tools or caches that exist only on your hosts.
Avoid self-hosting when nobody owns the fleet, when the repository is public, or when pull requests from forks run CI. A fork PR executes code you didn't write. On a persistent runner, that code can read leftover credentials or tamper with the next job. If untrusted code must run on your runners, three controls are required:
- Isolation: ephemeral runners that are discarded after a single job.
- Network control: a separate network segment with an egress allowlist and no route to internal services.
- Least-privilege credentials: short-lived tokens scoped to that one job.
| Option | Control | Locality | Ops burden |
|---|---|---|---|
| Hosted standard runners | Low | None | None |
| Larger hosted runner tiers | Medium (size, some networking options) | Limited | Low |
| Long-lived self-hosted VMs | High | Full | Patching, cleanup, state drift |
| Kubernetes-based fleet | High | Full | Cluster, autoscaling, image pipeline |
name: backend-build
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
runner:
description: 'JSON label list override, e.g. ["ubuntu-latest"]'
required: false
default: ''
permissions:
contents: read
jobs:
build:
# Resolution order, first non-empty value wins:
# 1. Fork PRs always go to a fresh GitHub-hosted VM (untrusted code).
# 2. A manual workflow_dispatch input overrides the runner.
# 3. The repo variable BACKEND_RUNNER, normally set to
# ["self-hosted","linux","x64","build-large"].
# During a fleet outage, set it to ["ubuntu-latest"]; no PR needed.
# 4. Hosted fallback if the variable is unset.
runs-on: ${{ fromJSON(github.event.pull_request.head.repo.fork && '["ubuntu-latest"]' || inputs.runner || vars.BACKEND_RUNNER || '["ubuntu-latest"]') }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
# Keep the job portable: install toolchains in the job instead of
# relying on tools that only exist on self-hosted hosts.
- run: make build test
docs:
# Light, infrequent work stays on hosted runners.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make docs
A workflow that sends heavy backend builds to self-hosted runners through a repository variable, lets a manual input override that choice, keeps fork pull requests and docs builds on GitHub-hosted runners, and falls back to ubuntu-latest when the variable is unset.
Measurement checklist before you migrate
Before changing any infrastructure, replace every assumed input from the earlier worked example with numbers from your own builds.
- Export usage data. Pull at least a month of billing and workflow run history. A single week can miss release spikes and quiet periods, and those swings decide whether a fixed fleet ever reaches useful utilization.
- Tag jobs by repository and workflow. Cost is rarely spread evenly. A few workflows often account for most minutes, and those are the only candidates worth moving.
- Record p50 and p95 for job duration and queue time. Medians show typical cost. The p95 shows what developers wait through during bursts, and it tells you how much headroom a self-hosted pool needs.
- Measure cache hit rates. A low hit rate on hosted runners points to a cache problem that you can fix before migrating, and that fix may remove the reason to migrate.
- Pilot one high-volume repository. Run it on self-hosted runners for a few weeks while the rest of your CI stays unchanged.
From the pilot, calculate cost per successful build as total runner spend divided by the number of builds that passed. Reruns and failed jobs belong in the spend, because a flaky runner that forces retries raises the real cost even when the price per minute looks lower.
This step can save more engineering time than any other. A pilot that shows no savings costs a few weeks of work. A full migration that shows no savings costs months of operating a fleet you have to keep running anyway.
After cutover, keep tracking these metrics:
- Cost per successful build
- p95 queue time
- Cache hit rate
- Rerun rate
- Runner utilization
- On-call hours spent on the fleet
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to self-hosted github runners cost, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.