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.

Monthly Cost vs Billed Minutes for Both Models The hosted line rises linearly from zero at eight tenths of a cent per minute. The self-hosted line starts at a fixed floor of about four hundred dollars covering instances and maintenance, then steps upward as fleet capacity is added. The two cross near thirty thousand minutes a month, and a shaded band shows the crossover moving between twenty and fifty thousand depending on fleet utilisation. $0 $300 $600 $900 crossover band β€” depends on utilisation hosted β€” $0.008/min, no floor self-hosted β€” $400 floor, then steps crossover β‰ˆ 30k min/month 0 20k 45k 75k billed minutes per month Below the crossover, hosted runners are cheaper AND require no operational work β€” the decision is not close.

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.

Why a Peak-Sized Fleet Is Mostly Idle Demand over seven days plotted against a flat capacity line at six concurrent runners. Weekday mid-mornings approach capacity, nights fall to near zero, and the weekend is almost flat. The shaded area between demand and the capacity line is billed but unused. 0 2 4 6 jobs fleet capacity β€” billed 24/7 Mon Wed Fri Sat–Sun Filled area = work done. Everything above it, up to the capacity line, is paid for and unused. Typical result: 10–15% utilisation, so the effective rate per useful minute is 7–10Γ— 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 rate

Expected shape of the first command:

UBUNTU     28,410
MACOS       1,205
WINDOWS       640

That 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.

Beyond the Rate: What Each Model Actually Costs You Five factors compared. Per-minute cost favours self-hosted. Idle cost, elasticity, security surface and operational load all favour hosted runners, which is why the crossover sits far higher than a naive rate comparison suggests. Hosted Self-hosted Cost per busy minute higher lower Cost while idle zero full capacity, billed Absorbing a spike automatic queue, or build autoscaling Untrusted code exposure ephemeral VM your network Ongoing engineer hours none 4–10 per month

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.

← Back to Tracking CI/CD Compute Costs for Platform Teams