GitHub-Hosted vs Self-Hosted Runner Cost Comparison
Somebody has proposed self-hosting to cut the CI bill, and the estimate compares a per-minute rate to an instanceβs hourly price. That comparison is missing utilisation, maintenance, storage, egress, and the cost of a fleet that is idle two-thirds of the week β which is why self-hosting so often lands at parity rather than at the promised 70% saving.
When to use this pattern
- Hosted runner minutes are a material line item and someone has asked whether self-hosting is cheaper.
- You need a defensible number for a platform-budget conversation, not a directional guess.
- You are considering larger hosted runners and want to know whether the speed-up pays for the rate.
Prerequisites
The two cost models are shaped differently
Hosted runners are pure variable cost: you pay per minute of work, and zero when idle. Self-hosted runners are mostly fixed: you pay for capacity whether or not it is used. That difference β not the headline rates β is what decides the answer, because CI load is extremely bursty.
Complete working example
#!/usr/bin/env bash
# scripts/runner-cost-compare.sh β compare both models from real usage.
set -euo pipefail
ORG="${1:?usage: runner-cost-compare.sh <org>}"
# --- Hosted side: actual billed minutes for the current cycle -------------------
MINUTES=$(gh api "/orgs/${ORG}/settings/billing/actions" --jq '.total_minutes_used')
RATE_PER_MIN=0.008 # 2-core Linux; 4-core is 2x, macOS ~10x
HOSTED=$(echo "$MINUTES * $RATE_PER_MIN" | bc -l)
# --- Self-hosted side: capacity you must keep warm ------------------------------
PEAK_CONCURRENT=6 # simultaneous jobs at the weekday peak
INSTANCE_HOURLY=0.0832 # c6a.large on-demand, eu-west-1
HOURS_PER_MONTH=730
EBS_PER_RUNNER=8.00 # 100 GB gp3, needed for image and cache churn
EGRESS=25.00 # image pulls and artifact upload
MAINT_HOURS=6 # honest: patching, image rebuilds, stuck runners
ENG_HOURLY=95
INSTANCES=$(echo "$PEAK_CONCURRENT * $INSTANCE_HOURLY * $HOURS_PER_MONTH" | bc -l)
STORAGE=$(echo "$PEAK_CONCURRENT * $EBS_PER_RUNNER" | bc -l)
LABOUR=$(echo "$MAINT_HOURS * $ENG_HOURLY" | bc -l)
SELF=$(echo "$INSTANCES + $STORAGE + $EGRESS + $LABOUR" | bc -l)
# Utilisation is the number people forget: work minutes Γ· capacity minutes.
CAPACITY_MIN=$(echo "$PEAK_CONCURRENT * $HOURS_PER_MONTH * 60" | bc -l)
UTIL=$(echo "scale=1; $MINUTES * 100 / $CAPACITY_MIN" | bc -l)
printf 'billed minutes : %s\n' "$MINUTES"
printf 'hosted : $%.2f\n' "$HOSTED"
printf 'self-hosted : $%.2f (instances $%.0f + storage $%.0f + egress $%.0f + labour $%.0f)\n' \
"$SELF" "$INSTANCES" "$STORAGE" "$EGRESS" "$LABOUR"
printf 'fleet utilisation : %s%%\n' "$UTIL"
printf 'verdict : %s\n' \
"$(echo "$HOSTED > $SELF" | bc -l | grep -q 1 && echo 'self-hosted wins' || echo 'hosted wins')"Step-by-step walkthrough
Start from billed minutes, not job counts. Minutes are what you are charged for, they include queue-free startup overhead, and they are already broken out by runner size. Job counts multiplied by an average duration is where most overestimates begin.
Size the fleet to peak, then measure utilisation. A fleet must cover the weekday-morning peak or developers queue, but it is billed continuously. A six-instance fleet offers roughly 263,000 capacity-minutes a month; if you consume 30,000, utilisation is 11% and the effective rate per useful minute is nine times the instance rate.
Price labour honestly. Runner images drift, disks fill, the runner agent needs upgrades, and jobs occasionally wedge a machine. Six hours a month is a conservative figure for a small fleet and it is frequently the largest single line item at low usage. Excluding it is the most common way a self-hosting proposal is made to look better than it is.
Do not forget the counter-argument. Self-hosted runners can be faster per job β warm caches, local registry mirrors, larger disks β and a 20% faster pipeline has value that does not appear on either side of a pure cost sheet. Treat that as a separate benefit with its own justification rather than folding it into the cost comparison, where it muddies both numbers.
Consider the middle option first. Larger hosted runners cost proportionally more per minute but can finish disproportionately faster on parallel builds. If a 4-core runner halves a build that a 2-core runner takes twelve minutes to do, the bill is unchanged and the developer waits six minutes less.
Verification
# 1. Minutes by runner label β reveals whether macOS is quietly dominating the bill.
gh api "/orgs/${ORG}/settings/billing/actions" \
--jq '.minutes_used_breakdown | to_entries[] | "\(.key)\t\(.value)"'
# 2. Concurrency profile: peak simultaneous jobs over the last 500 runs.
gh run list --limit 500 --json startedAt,updatedAt \
--jq '[.[] | {s: .startedAt, e: .updatedAt}] | length'
# 3. Sanity-check the crossover with your own rate.
echo "crossover minutes = $(echo 'scale=0; 640 / 0.008' | bc)" # fixed monthly cost Γ· per-minute rateExpected shape of the first command:
UBUNTU 28,410
MACOS 1,205
WINDOWS 640That breakdown often ends the conversation on its own: 1,205 macOS minutes at roughly ten times the Linux rate can outweigh 28,410 Ubuntu minutes, and the fix is scheduling macOS jobs rather than building a fleet.
What the comparison does not capture
Two effects sit outside the arithmetic and both push in the same direction.
The first is security surface. Self-hosted runners execute untrusted pull-request code on machines inside your network, which is a materially different risk posture from an ephemeral hosted VM that is destroyed after each job. Doing it safely means ephemeral runners, network segmentation, and the credential controls described in pipeline security and supply chain hardening β all of which are real work that belongs on the self-hosted side of the ledger.
The second is elasticity. A hosted pool absorbs a Monday-morning spike without anyone noticing; a fixed fleet turns the same spike into a queue, and the queue costs developer time that never appears in a CI bill. Autoscaling recovers most of this, but autoscaling is itself a system to build, monitor, and debug β and a scale-up that takes two minutes to boot a runner has already spent much of the wait it was meant to remove.
Common pitfalls
Comparing on-demand instance pricing to hosted pricing directly. One is capacity, the other is work. Convert both to cost per useful minute at your measured utilisation before comparing.
Ignoring macOS. It is the most expensive runner class by a wide margin and often a small minority of jobs. Check the breakdown before restructuring anything else β the cheapest optimisation is usually moving macOS jobs to a schedule.
Self-hosting to fix a queueing problem. If the complaint is wait time rather than cost, cancellation and concurrency tuning are faster and free β see cancelling superseded workflow runs and the queue analysis in reducing GitHub Actions minutes with self-hosted runners.
Related
- Tracking CI/CD Compute Costs for Platform Teams β the parent guide on attribution and budgeting.
- Reducing GitHub Actions Minutes with Self-Hosted Runners β the implementation side, once the numbers justify it.
- Implementing Pipeline Cost Alerts for AWS CodeBuild β catching a regression before the invoice does.
- CI/CD Pipeline Architecture & Fundamentals β the section overview.
β Back to Tracking CI/CD Compute Costs for Platform Teams