Originally published on kuryzhev.cloud
Your team moved CI to a pool of self-hosted runners two years ago because the hosted minutes bill kept growing. Now a finance person forwards a GitHub announcement and asks whether the pool is still worth the on-call load. That question deserves a calculation, not a gut call, because the numbers behind self-hosted runners changed at the start of the year.
GitHub-hosted Actions runners have been up to 39% cheaper since 1 January 2026, and the planned fee for self-hosted runner usage was postponed. Both points are in the official changelog. The postponement is not a cancellation, so any plan that depends on "self-hosted is free on the GitHub side" has an expiry risk.
Short answer: default to GitHub-hosted runners, and keep or add self-hosted runners only for a specific reason. Good reasons are:
- private network access
- special hardware
- compliance boundaries
- sustained high-volume load where your measured cost per minute is clearly lower
If your only reason was "hosted minutes are expensive", re-run the numbers first.
When this choice matters
The decision is not worth much attention for a small repo with a few short workflows. It matters in four situations:
- You run a large monthly volume of CI minutes, especially on larger runner sizes.
- Jobs need to reach private resources such as an internal registry, a database, or a Kubernetes API that is not public.
- You need GPUs, ARM boards, macOS fleets, or big-memory machines that the standard hosted sizes do not cover.
- An auditor requires builds to execute inside a specific network or account boundary.
Before comparing anything, measure what you actually run. The command below lists recent runs and sums wall-clock minutes per workflow. It is only an approximation, because billing counts minutes per job and rounds each job up to the minute.
# Approximate minutes per workflow from the last 200 runs (needs gh CLI, logged in) gh run list --repo OWNER/REPO --limit 200 \ --json workflowName,startedAt,updatedAt \ --jq 'group_by(.workflowName) | map({workflow: .[0].workflowName, minutes: (map((.updatedAt|fromdate) - (.startedAt|fromdate)) | add / 60 | floor)}) | sort_by(-.minutes)' Then compare the result with the billing usage report in your organization settings. The GitHub Actions documentation describes the current billing views.
Option A: GitHub-hosted runners
Pros
- No fleet to run. Images, patching, scaling, and cleanup are GitHub's job. Every job starts on a fresh VM, so there is no state leaking between runs.
- The price dropped. The reduction is "up to" 39%, so the real saving depends on runner type and size. Check the current rate table instead of assuming the maximum.
- Safer for untrusted code. Pull requests from forks are far less dangerous on throwaway VMs than on a machine inside your network. You still need careful workflow triggers and secret handling.
- Elastic. A burst of parallel jobs on release day needs no capacity planning, up to your plan's concurrent-job limit.
Cons
- Per-minute billing punishes slow jobs. A 25-minute build with no caching costs the same every time.
- Reaching private networks needs extra plumbing: a VPN, an overlay network, or a paid private-networking feature. Verify the current options in the docs.
- Hardware choices are limited to what GitHub offers. Larger runners include some GPU and ARM options, so check the current list before ruling them out.
- Cold caches. Dependency and Docker layer caches have to be restored on every run.
The cheapest improvement here costs nothing. Cache dependencies, cancel superseded runs, and set job timeouts so a hung job does not burn minutes:
# .github/workflows/ci.yml name: ci on: [pull_request] concurrency: group: ci-${{ "{{" }} github.ref }} cancel-in-progress: true # stop paying for outdated pushes jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 # a hung job must not bill for 6 hours steps: - uses: actions/checkout@v6 # verify the latest major tag before pinning - uses: actions/setup-node@v6 with: node-version: 26 # Node.js 26 LTS cache: npm - run: npm ci && npm test Option B: Self-hosted runners
Pros
- Network position. Jobs run next to your private services, so no tunnels are needed for integration tests or deployments.
- Hardware freedom. You can use GPUs, large-memory hosts, or specific CPU architectures.
- Warm caches. A host-level Docker layer cache, a dependency mirror, or a shared cache volume can cut wall-clock time considerably. With ephemeral runners, the cache must live outside the runner's disposable environment to survive between jobs. The gain depends on your build.
- Predictable cost at high, steady volume. You pay for machines whether or not jobs run, which wins only when utilization is high.
Cons
- You own the fleet: OS patching (a current host base is Ubuntu 26.04 LTS), runner version updates, disk cleanup, and autoscaling.
- Security blast radius. A compromised workflow on a long-lived runner can read secrets from earlier jobs and reach your network. Do not attach persistent runners to public repositories.
- Idle cost. Capacity sized for peak hours sits unused overnight.
- Policy risk. The postponed self-hosted fee may return. Check the changelog before committing to multi-year plans.
If you go self-hosted, register ephemeral runners that take one job and then deregister. The flags below are the documented runner options. The registration token comes from the REST API.
Requesting the token requires an org-admin credential. Run step 1 from a supervisor or controller, not on the runner host, so jobs cannot read that credential.
# 1. On the supervisor: get a short-lived registration token (org-level runners) TOKEN=$(gh api -X POST /orgs/ORG/actions/runners/registration-token --jq .token) # 2. On the runner host: configure an ephemeral runner in a group restricted to private repos ./config.sh --url https://github.com/ORG --token "$TOKEN" \ --name "build-$(hostname)-$RANDOM" \ --labels linux,x64,private-net \ --runnergroup private-ci \ --ephemeral --unattended --replace ./run.sh # takes exactly one job, then exits and deregisters # Restarting run.sh alone will not work: fetch a new token and re-run config.sh # (ideally on a fresh VM or container) before the next job. # 3. Verify registration and state gh api /orgs/ORG/actions/runners \ --jq '.runners[] | [.name, .status, .busy] | @tsv' # expected shape: build-host-1234 online false Point jobs at it with runs-on: [self-hosted, linux, private-net]. For Kubernetes-based autoscaling, start from the self-hosted runner documentation. Treat any controller choice as a separate evaluation.
Decision matrix
| Situation | Better fit | Why |
|---|---|---|
| Small or medium volume, public internet only | GitHub-hosted | No ops cost; the price cut widens the gap |
| Open-source or public repo with fork PRs | GitHub-hosted | Ephemeral VMs limit damage from untrusted code |
| Jobs must reach private databases or clusters | Self-hosted (ephemeral) | Network position beats tunnels, but isolate by runner group |
| GPU, exotic architecture, huge RAM | Self-hosted | Hosted sizes may not exist for it |
| Steady, high, 24/7 load with measured high utilization | Self-hosted, maybe | Only if your cost per minute, including staff time, is lower |
| Bursty load with long idle periods | GitHub-hosted | Idle capacity makes self-hosted expensive |
| Regulated builds that must stay in one boundary | Self-hosted | Compliance outweighs cost |
Use this formula for the cost column and fill it with your own numbers:
# Self-hosted monthly cost per CI minute actually used (compute + storage + network + licenses + engineer_hours_per_month * loaded_hourly_rate) / billable_job_minutes_per_month # Compare against the current hosted per-minute rate for the same runner size. # Add a margin for the postponed self-hosted platform fee, if it returns. Evidence-based recommendation
The published evidence is limited to two facts from GitHub: hosted prices fell by up to 39%, and the self-hosted fee was deferred, not removed. Everything else depends on your workload, so treat vendor blog numbers and anyone's anecdote as inputs, not conclusions.
Here is a defensible order of operations:
- Run the measurement command above and pull the billing report. Find the three workflows that consume the most minutes.
- Move everything that does not need private access or special hardware to GitHub-hosted runners. Add caching,
concurrencycancellation, andtimeout-minutes. - Keep self-hosted runners only for jobs with a named reason. Make them ephemeral, put them in a restricted runner group, and never attach them to public repositories.
- Recompute the formula quarterly, and again when GitHub revisits the self-hosted fee.
Watch out for: hidden staff time. A pool that "costs nothing" in cloud spend but takes a few engineer-days a month to patch and debug is often not cheaper.
Also watch for non-ephemeral runners that quietly accumulate disk usage, credentials, and stale tool versions. A typical failure is a full disk that fails every job on one host while the rest of the pool looks healthy.
When not to self-host: if nobody owns the fleet, if you build untrusted fork code, or if utilization is below your formula's break-even point. A hybrid setup is common and reasonable: hosted runners for most pipelines, plus a small self-hosted pool for the few jobs that need the network or hardware. For related CI/CD decisions, browse the rest of kuryzhev.cloud.

![Embodied AI and Robotics — Ch.22: Real World Deployment [EN]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqgoeab9bgep6ipok2kym.png)