How are GitHub Actions minutes billed, and what does a self-hosted runner actually cost?

GitHub-hosted runners are virtual machines that GitHub provisions for each job and discards afterward. Self-hosted runners are machines you operate that run the GitHub Actions runner agent and pull jobs from your repositories.

Hosted billing works on GitHub Actions minutes. Three details matter for cost:

  • Rates vary by OS and machine size. Linux is the cheapest per minute. Windows and macOS cost more. Larger runners with more cores or GPUs cost more again.
  • Included minutes vary by plan. Your account gets a monthly allowance, and you pay overage only after you use it up. Public repositories on standard runners are treated differently from private ones.
  • Each job rounds up to the nearest minute. A 10-second lint job is billed as a full minute. A pipeline with many short jobs therefore costs more than its wall-clock time suggests.

Pricing and allowances change, so check the current billing page for your plan before you build any model. Don't rely on figures quoted in blog posts, including this one.

Self-hosted runners have historically not been billed per minute by GitHub. Confirm the current terms for your plan. What you pay for instead is infrastructure and labor.

Cost driver GitHub-hosted Self-hosted
Compute Per-minute rate by OS and size VMs, nodes, or hardware you pay for whether busy or idle
Maintenance None Patching, images, runner upgrades, security hardening
Scaling Automatic You build and operate it
Hardware and network control Limited to offered sizes Full: custom CPUs, GPUs, private network access

Backend teams usually start considering self-hosting for three reasons:

  • Long integration test suites that run against databases and message brokers.
  • Container image builds, which benefit from a warm layer cache that persists between jobs.
  • Jobs that need private network access or specific hardware. Hosted runners make this awkward or expensive.

How do self-hosted runners work?

A self-hosted runner is the same open-source runner application that GitHub uses on its hosted VMs, installed on a machine you control. The lifecycle has four parts.

Registration. You download the runner application and run its configuration script with your repository, organization or enterprise URL and a short-lived registration token from the GitHub settings page or API. Registration attaches the runner at that level and assigns it labels. Every runner gets default labels such as self-hosted, its operating system and its architecture, and you can add custom labels such as ci-heavy. At the organization and enterprise levels, runner groups control which repositories may use which runners.

Job pickup. The runner opens an outbound HTTPS connection to GitHub and long-polls for work. It doesn't need inbound ports or a public IP address, which is why runners can sit inside private networks. When a workflow job's runs-on value matches a runner's labels, or names a runner group or ARC scale set, GitHub assigns the job to an idle matching runner.

Execution. The runner downloads the job definition, checks out code through the steps you define, runs actions and shell commands as the runner's OS user, and streams logs and status back to GitHub. Anything already on the machine is available to the job: installed toolchains, Docker, cached dependencies, network routes to internal services, and any credentials present on the host.

Persistent vs ephemeral. By default a runner stays registered and takes job after job, so files and state from one job remain for the next. A runner configured as ephemeral takes a single job and then deregisters. You replace it with a fresh machine or container, which is the model Actions Runner Controller automates on Kubernetes.

GitHub still orchestrates the workflow: triggers, job dependencies, secrets, logs and the UI all work as they do with hosted runners. You own only the execution environment, and with it the capacity, patching, isolation and runner software updates.

How do you build a CI spend baseline from your own pipeline data?

Before comparing anything, measure what you run. The billing page shows totals, but a migration decision needs job-level data: which workflows consume minutes, on which runner sizes, and how often jobs overlap.

Export job timing data

gh run list --json databaseId,workflowName,createdAt,conclusion --limit N lists recent runs for a repository. For each run ID, gh api repos/{owner}/{repo}/actions/runs/{run_id}/jobs --paginate returns its jobs with created_at, started_at, completed_at, labels, and runner_name. Collect at least a full month so release weeks and nightly schedules are included. Repeat for every repository that uses Actions.

Aggregate

Write one row per job containing workflow, labels, duration (completed_at − started_at), and queue time (started_at − created_at). From those rows:

  • Monthly minutes by runner label. Larger runners bill at a higher per-minute multiplier, so total by size, not just overall. GitHub-hosted billing rounds each job up to the whole minute, so many short jobs cost more than their summed duration suggests.
  • Minutes by workflow. Sort descending. In most backend organizations, a few pipelines dominate the total, typically integration tests that start databases and brokers, and container image builds. Those few workflows are what any self-hosted design must handle well.
  • Peak concurrency. Sweep over job start and end timestamps and record the maximum number of overlapping jobs. This number determines how much capacity you need, which average utilization hides.
  • Queue time percentiles. This is your current latency baseline. A self-hosted fleet should not make it worse.
#!/usr/bin/env bash
set -euo pipefail
REPO="example-org/payments-api"   # repeat for every repo that uses Actions
SINCE="2026-10-01T00:00:00Z"       # cover at least one full month
OUT="jobs.tsv"

gh run list --repo "$REPO" --limit 5000 --json databaseId,workflowName,createdAt \
  --jq ".[] | select(.createdAt >= \"$SINCE\") | [.databaseId, .workflowName] | @tsv" |
while IFS=$'\t' read -r run_id workflow; do
  # Skip jobs that never ran (no start/end timestamps)
  gh api --paginate "repos/$REPO/actions/runs/$run_id/jobs?per_page=100" \
    --jq '.jobs[] | select(.started_at and .completed_at)
      | (.started_at|fromdateiso8601) as $s | (.completed_at|fromdateiso8601) as $e
      | [(.labels|join(",")), ($e-$s), ($s-(.created_at|fromdateiso8601)), $s, $e] | @tsv' \
    </dev/null | awk -v wf="$workflow" '{print wf "\t" $0}'
done > "$OUT"

echo "== Billed minutes by runner label (each job rounded up to a whole minute)"
awk -F'\t' '{m[$2]+=int(($3+59)/60)} END{for(k in m) print m[k] "\t" k}' "$OUT" | sort -rn

echo "== Billed minutes by workflow (expect a few to dominate)"
awk -F'\t' '{m[$1]+=int(($3+59)/60)} END{for(k in m) print m[k] "\t" k}' "$OUT" | sort -rn | head -10

echo "== Peak concurrency (+1 at start, -1 at end; ends sort first on ties)"
awk -F'\t' '{print $5, 1; print $6, -1}' "$OUT" | sort -k1,1n -k2,2n |
  awk '{c+=$2; if (c>p) p=c} END{print "peak concurrent jobs:", p}'

echo "== Queue time percentiles (seconds)"
cut -f4 "$OUT" | sort -n |
  awk '{q[NR]=$1} END{print "p50:", q[int((NR-1)*0.5)+1], " p95:", q[int((NR-1)*0.95)+1]}'

A bash script that uses gh run list and the jobs API to export per-job timings to TSV, then computes rounded billed minutes by runner label and by workflow, peak job concurrency, and queue-time percentiles.

What goes into the total cost of self-hosted runners?

A self-hosted runner's cost is not the instance price. It is the instance price divided by how much of that capacity jobs actually use, plus everything needed to keep the fleet healthy. You size for peak concurrency, so off-peak capacity sits idle unless autoscaling removes it.

Cost category What drives it Why it gets missed
Compute Instance size × hours provisioned Billed for idle hours, not job minutes
Over-provisioning Sizing for peak concurrency and the largest job Hidden behind average utilization
Storage Dependency caches, Docker layer caches, disk volumes Grows slowly until disks fill
Network Image pulls, artifact uploads, cross-zone or internet egress Billed on a separate line item
Observability Logs, metrics, alerting for runners Shared with other teams' budgets
Engineer time Patching, runner software updates, base images, autoscaling config, flaky-infra debugging, on-call Not a cloud bill line at all

Labor is the category spreadsheets undercount most. Labor shows up as interrupted sprints, not as an invoice. Runner software updates are not optional: an outdated runner can stop picking up jobs. Base images drift from what developers expect. Failures caused by full disks or stale caches look like flaky tests until someone traces them to the infrastructure.

A usable formula:

Monthly cost = (instance cost ÷ utilization) + storage + network + observability + (engineer hours × loaded hourly rate)

You also take on capacity risk. When a release-day spike exceeds your fleet, jobs queue, and adding capacity becomes your team's job.

When do self-hosted runners break even? An illustrative estimate

Illustrative estimate (assumed numbers, not a benchmark). Every input below is an assumption. Replace each one with figures from your own baseline.

  • Monthly job minutes: 200,000
  • Average pipeline run: 25 job-minutes, which gives 8,000 runs per month
  • Peak concurrency: 30 jobs
  • Hosted rate: $0.016 per minute for a larger runner
  • Self-hosted instance: $0.20 per hour
  • Fleet utilization: 50%
  • Fixed overhead (storage, network, observability): $200 per month
  • Maintenance: 10 engineer hours per month at a $120 loaded rate

Self-hosted compute is (minutes ÷ 60) ÷ utilization × instance price. In this example that is 6,667 instance-hours, or $1,333. Adding $200 of overhead and $1,200 of labor gives $2,733. Hosted runners would cost $3,200.

Break-even minutes = fixed costs ÷ (hosted per-minute price − instance price ÷ (60 × utilization)). With these inputs that works out to 150,000 minutes. If utilization drops to 25%, the break-even point moves to 525,000 minutes.

Scenario Self-hosted monthly Self-hosted per run Hosted per run
Baseline (50%, 10 h) $2,733 $0.34 $0.40
Utilization 25% $4,067 $0.51 $0.40
Labor 20 h/month $3,933 $0.49 $0.40

The margin is narrow. A single bad assumption, either idle capacity or extra maintenance time, reverses the result. Utilization matters most because it multiplies the entire compute line.

Compare cost per pipeline run, not cost per minute. Warm dependency caches on persistent runners can make each run shorter, and a larger hosted runner can finish the same work in fewer minutes at a higher rate. A per-minute comparison misses both effects.

Prices for hosted minutes and for instances change over time. Rerun this calculation on a regular schedule and whenever either price changes.

How does autoscaling with Actions Runner Controller change the utilization math?

Hosted runners bill only for the minutes a job runs. A static self-hosted fleet costs the same at 3 a.m. on a Sunday as it does during a release push. That makes utilization the variable that decides the comparison. Assume a fleet that is busy 25% of the time (an illustrative assumption). Each billed job-minute then carries the cost of four instance-minutes, and that overhead can erase a lower per-minute price.

Actions Runner Controller (ARC) attacks this directly. It runs as a controller in your Kubernetes cluster and watches GitHub for queued jobs that target a runner group it manages. When jobs appear, it creates runner pods. Each pod registers as an ephemeral runner, takes exactly one job, and is torn down afterward. Capacity follows demand, and no job inherits state from a previous one.

The trade-offs:

  • Cold starts. If the pool scales to zero, each job waits for pod scheduling, image pulls, and possibly a new node.
  • Warm pools cost money. Keeping a minimum number of idle runners removes most of that delay, but it reintroduces the around-the-clock cost you were trying to avoid.
  • Scale-up lag. Developers experience it as queue time, which is easy to overlook in a cost model.
  • A new failure point. If the controller or its cluster is unhealthy, jobs queue indefinitely.

A hybrid setup limits the exposure. Route heavy, long-running backend jobs such as integration suites and container builds to the ARC runner set by using its runs-on label. Keep short lint and unit jobs on ubuntu-latest, where cold starts and controller outages don't apply.

name: backend-ci

on:
  pull_request:
  push:
    branches: [main]

jobs:
  lint-and-unit:
    # Short jobs: hosted runners bill only job time and have no
    # cold start or controller dependency.
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make lint
      - run: make test-unit

  integration:
    needs: lint-and-unit
    # Name of the ARC runner scale set (its Helm release name).
    # ARC sees this queued job and creates one ephemeral runner pod for it.
    runs-on: arc-backend-runners
    # Cap waits caused by scale-up lag or an unhealthy controller.
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v4
      - run: make test-integration

  image-build:
    needs: lint-and-unit
    # Long container builds also go to the autoscaled pool, so
    # we pay for cluster capacity only while these jobs exist.
    runs-on: arc-backend-runners
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - run: make image

A workflow that keeps lint and unit tests on GitHub-hosted ubuntu-latest runners while routing integration tests and container builds to an ARC runner scale set via its runs-on name.

How can self-hosted runners save engineering time?

The cost model above counts dollars. For many backend teams, the bigger effect is how long developers wait for CI and how much glue code CI needs. Self-hosted runners can reduce both, but only for specific workloads.

Shorter feedback loops from warm state. A fresh hosted VM starts empty, so every job restores or downloads dependencies, pulls base images and rebuilds container layers. A runner with a persistent Docker layer cache, or a local registry mirror and module proxy, skips much of that. The time saved per run is small, but it repeats on every push from every developer, and it lands on the critical path that people actually wait on.

Hardware sized to the job. You can give integration suites and builds the CPU, memory and fast local disk they need, instead of choosing among fixed hosted sizes. A CPU-bound compile or a test suite that runs databases in containers often finishes sooner on a larger machine, which shortens the wait on every pull request.

Less glue for private access. Integration tests that need an internal database, artifact repository or staging service can reach it directly from a runner inside your network. You don't need tunnels, IP allowlists for hosted runner ranges, or test doubles that exist only to make CI work. That removes a category of CI-only code that someone has to write and maintain.

Preinstalled toolchains. Baking compilers, SDKs and CLIs into the runner image removes repeated setup steps from every workflow and keeps tool versions consistent across repositories.

These gains have to be weighed against the time the fleet consumes: image maintenance, runner upgrades, autoscaling configuration, and debugging failures that turn out to be infrastructure problems. Measure both sides. Use the queue-time and duration data from your baseline to see whether runs actually got faster after the move, and track the hours spent on runner work in the same terms as the maintenance line in the cost formula. If developer wait time drops and maintenance hours stay predictable, the time savings are real. If the platform team keeps getting pulled into runner incidents, the savings have simply moved from one group to another.

Who should use self-hosted runners?

Self-hosted runners fit teams where most of the following are true:

  • Sustained, high CI volume. Your baseline shows enough steady monthly minutes that a fleet, autoscaled or not, stays well utilized. Volume that is low or concentrated in short bursts favors per-minute billing.
  • A few heavy backend workflows dominate spend. Integration suites against real databases and brokers, container image builds, and large compiles benefit most from warm caches and bigger machines.
  • Jobs need what hosted runners don't offer. Examples include private network access, specific CPU architectures, GPUs, or more memory and disk than the hosted sizes you can use.
  • Someone owns the platform. A platform or infrastructure team already runs Kubernetes or VM fleets, can treat runners as a production service with patching, monitoring and on-call, and has time budgeted for it.
  • Code comes from trusted contributors. Private repositories with internal contributors limit the risk of running arbitrary pull request code on your infrastructure.

If a team meets only the volume condition, the alternatives in the last section are usually cheaper. If it meets the hardware or network condition but has low volume, a small, dedicated self-hosted pool for just those jobs, with everything else on hosted runners, is often enough. The next section covers the conditions that argue against self-hosting.

When should you avoid self-hosted runners?

Self-hosting is not the right default for every team, and several conditions should push you toward hosted runners.

Low or spiky volume. A self-hosted fleet costs money whether or not jobs are running. If your pipeline runs a few hundred minutes a week, or bursts around release days and sits idle otherwise, per-minute billing tracks your actual usage. A fixed fleet makes you pay for idle capacity.

Public repositories and untrusted triggers. Anyone can open a pull request against a public repo. If that workflow runs on your runner, a contributor's modified test script executes inside your network, next to your cloud credentials and internal services. Workflows triggered by pull_request_target, or that check out fork code, carry the same risk in private repos that accept outside contributions.

Persistent runners. A runner that survives between jobs keeps files, Docker images, environment leftovers and cached tokens. One job can read what another left behind, and a compromised job can plant something for the next one. Ephemeral runners reduce this risk but add provisioning work.

Warm caches. Preinstalled toolchains and populated dependency caches speed up builds, but they also hide problems. A missing lockfile entry or an unpinned system package passes locally and on CI, then fails on a clean machine.

Egress. If runners pull large images or artifacts across regions or out of a cloud provider, data transfer charges can cancel out the compute savings.

Operational time. Patching images, rotating credentials and debugging stuck runners takes engineering hours. A small team often cannot spare that time.

What are the alternatives to migrating, and how can you cut CI spend first?

Cutting minutes you don't need is cheaper than any migration, and it shrinks the baseline you'd later compare against.

Cache aggressively. Restoring dependencies and Docker layers between runs replaces repeated downloads and rebuilds with a restore step. Order Dockerfile instructions so that rarely changing layers come first.

Skip work that can't matter. Path filters stop a docs-only change from triggering the full backend suite. Concurrency groups with cancel-in-progress kill runs for commits that have already been superseded by a newer push to the same branch.

Shorten the critical path. Test sharding splits a long suite across parallel jobs. A larger hosted runner bills at a higher per-minute rate, but if a CPU-bound build finishes proportionally faster, total cost stays roughly flat while developers wait less. Measure before assuming it works for your workload. The reverse applies too: lint jobs rarely need more than the smallest runner.

Prune the matrix. Testing every runtime version on every pull request multiplies cost. Run the full matrix on the main branch or nightly, and run a single representative entry on pull requests.

The hybrid pattern

Keep lint, type checks and unit tests on hosted runners, where startup is fast and maintenance is zero. Route integration tests, image builds and anything that needs warm caches or private network access to labeled self-hosted pools. This puts the operational burden only where the savings justify it.

Keep the split honest

Set budget alerts on hosted usage. Attribute spend per workflow and per runner label, so that when a job's profile changes you can see whether it still belongs where it runs.

name: backend-ci
on:
  pull_request:
    # Skip the backend suite for docs-only changes
    paths-ignore:
      - 'docs/**'
      - '**/*.md'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint-unit:
    # Cheap, fast jobs stay on hosted runners
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'  # single representative version on PRs
          cache: true         # restore module and build cache
      - run: go vet ./... && go test -short ./...

  integration:
    # Heavy jobs go to a labeled self-hosted pool with warm caches
    runs-on: [self-hosted, linux, ci-heavy]
    strategy:
      matrix:
        shard: [1, 2, 3, 4]  # split the suite across parallel jobs
    steps:
      - uses: actions/checkout@v4
      - run: ./scripts/integration-tests.sh --shard ${{ matrix.shard }} --total 4

A GitHub Actions workflow combining path filters, cancel-in-progress concurrency and dependency caching, with lint and unit tests on hosted runners and sharded integration tests on a labeled self-hosted pool.

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