Scaling Self-Hosted Runners Without Owning the Cluster
Scaling self-hosted GitHub Actions runners means owning registration, autoscaling, image rebuilds, and cleanup. The managed and BYOC paths, priced.
Last verified:
Scaling self-hosted GitHub Actions runners is four standing jobs rather than one: registering runners with GitHub, growing and shrinking the fleet against queue depth, rebuilding the runner image on GitHub's 30-day software update clock, and cleaning up the machine and its registration record after every job. You can own all four on a cluster you operate, hand all four to managed runners behind a runs-on label, or split the difference with BYOC, where the scaling machinery is operated for you while the instances stay in your own cloud account.
This guide covers what a self-managed pool has to solve, the responsibility split across the three shapes as a table, the workflow change each shape needs, and a worked month that prices the middle path.
Diagnosis
A pool that works on a Tuesday afternoon and stalls at 6 pm on a Friday is the normal failure mode. Four subsystems decide whether that happens, and each one fails differently.
Registration and deregistration. A runner joins a repository, organization, or enterprise scope and answers jobs whose runs-on labels it carries, with access controlled through runner groups (manage access, checked on 2026-08-13). At one runner per job, registration happens as often as jobs do, so it becomes an API path with rate limits and credentials rather than a setup step. Actions Runner Controller solves it with a listener that holds an outbound long poll and registers each runner pod with a just-in-time configuration (ARC concepts, checked on 2026-08-13). A pool of virtual machines needs the same flow built and kept credentialed.
Autoscaling against queue depth. Capacity has to follow a signal. On ARC that signal arrives on the listener connection, and the bounds are the static Helm values minRunners and maxRunners; GitHub documents that scheduled maximum and minimum configurations are not supported and suggests a cron job to update the configuration on a schedule (deploy runner scale sets, checked on 2026-08-13). Every policy you write sits between two failure modes: warm capacity that bills while idle, and scale to zero that pays a boot on every job.
Image maintenance on a fixed clock. GitHub states that if you do not perform a software update within 30 days, the GitHub Actions service will not queue jobs to your runner (runner software updates, checked on 2026-08-13). The actions/runner project shipped 11 stable releases in the 12 months to 2026-08-13 (releases). Your compilers, SDKs, and container tooling ride on top of that cadence, and the ARC runner image carries only the packages the runner binaries need, so a real toolchain means an image you build and rebuild.
Cleanup after every job. One job per runner is the isolation boundary most teams want, which means the machine is destroyed and its registration record removed when the job ends. Skip it and job state leaks into the next job, offline runner entries pile up in the scope, and instances survive their controller. The lifecycle a runner goes through under a managed control plane is written out in the BYOC runner lifecycle.
Upgrades sit behind all four. GitHub documents that Helm cannot upgrade ARC and that the procedure is an uninstall and reinstall, with a second cluster in another region as the documented way to keep runners available through the window (upgrading ARC). GitHub also recommends a Kubernetes expert on staff and publishes a list of Kubernetes topics outside its support scope (ARC support scope, checked on 2026-08-13). The component detail is in Actions Runner Controller.
Why the pool is either idle or queuing
Take one month of GitHub Actions history: 45,000 Linux job minutes, and a measured peak of 40 concurrent jobs during the weekday merge window.
| Quantity | Arithmetic | Result |
|---|---|---|
| Job hours in the month | 45,000 divided by 60 | 750 hours |
| Hours in the month | 30 times 24 | 720 hours |
| Average concurrent jobs | 750 divided by 720 | 1.04 |
| Peak concurrent jobs | Measured from queue depth | 40 |
| Standing pool sized for peak | 40 times 720 | 28,800 instance hours |
| Utilization of that pool | 750 divided by 28,800 | 2.6 percent |
The gap between 1.04 and 40 is the whole problem. Size for the average and Friday evening queues. Size for the peak and hold 28,800 instance hours to serve 750 hours of work. Every autoscaler is an attempt to live somewhere between those two numbers, and the number it lands on is the one that decides the bill.
Fix
Pick the shape by which of the four subsystems you want to keep, then set the runner labels to match. There are three shapes.
Self-managed pool. A controller on a Kubernetes cluster you run, or virtual machines plus a scaler you wrote. You keep registration, autoscaling policy, images, cleanup, upgrades, and the cluster underneath them.
Managed runners behind a label. The runner fleet is operated for you and reached by a runs-on label. Runners are ephemeral virtual machines, freshly allocated when a job needs one and destroyed when the workflow completes (cloud runners, checked on 2026-08-13).
BYOC. The control plane is operated for you and the instances run in your cloud account, inside a stack you create in a region of your choosing. Setup is three steps: connect the cloud account, create a stack, create a custom runner (BYOC documentation, checked on 2026-08-13).
The responsibility split
| Job to be done | Self-managed pool | Managed runners | BYOC |
|---|---|---|---|
| Runner registration and deregistration | Yours | Operated for you | Operated for you |
| Autoscaling policy and queue-depth signal | Yours | Operated for you | Operated for you |
| Runner image build and 30-day rebuild clock | Yours | Operated for you | Operated for you |
| Per-job cleanup and isolation | Yours | Operated for you | Operated for you |
| Control plane upgrades | Yours | Operated for you | Applied through the dashboard when a stack update appears |
| Cloud account, IAM, and quota headroom | Yours | None to run | Yours |
| Region choice | Yours | Managed | Yours, fixed per stack at creation |
| Who pays for the compute | Your cloud invoice | Inside the per-minute rate | Your cloud invoice |
| Spot instances and custom base images | Yours to build | Managed | Available on your instances |
Sources: the BYOC documentation and the cloud runners catalog, both checked on 2026-08-13.
The decision, in order
- Does a policy require build compute to run inside your own cloud account? Then BYOC. BYOC runs on AWS, GCP, and Azure, and the AWS setup path is written out in running GitHub Actions runners in your own AWS account.
- Is the cluster itself doing work for you, such as pod-level isolation or node pools you already operate? Then keep the self-managed pool, and budget the four subsystems as standing work.
- Otherwise, take the labels. Registration, the autoscaler, the image clock, and cleanup all move off your plate at once. The axis-by-axis version of this choice is in choosing between hosted and BYOC runners.
Configuration
Moving off a self-managed pool changes one line per job. The labels below are Linux x64 sizes from the cloud runners catalog.
name: ci
on:
push:
branches: [main]
jobs:
unit:
runs-on: warp-ubuntu-latest-x64-4x
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v5
- run: make test SHARD=${{ matrix.shard }}
integration:
needs: unit
runs-on: warp-custom-use1-linux-8x
steps:
- uses: actions/checkout@v5
- run: make integrationThe unit job runs on a managed label. The integration job runs in your own cloud account: warp-custom-use1-linux-8x is the Runner ID from the custom runners page, and the warp-custom- prefix is part of the ID. The steps under both jobs are identical, and the matrix no longer needs a maxRunners value raised anywhere to widen.
Three settings do the scaling work on the BYOC side, all from the BYOC documentation:
- List several instance types on one custom runner. When capacity for one type is unavailable in the region, the runner falls back to another. Choose types with similar performance so job times stay stable, and treat this as required on spot rather than optional.
- Name stacks by product and region. The stack name, object storage location, and region cannot be changed after creation, so
payments-use1ages better thanstack-1. - Leave static IPs off unless an allowlisted outbound address is a hard requirement. Enabling them puts runners in private subnets behind managed network address translation, which adds data processing charges on every gigabyte a job moves.
Cost or Time Model
Same month as the Diagnosis section: 45,000 Linux job minutes, an 8 vCPU and 32 GB shape, a measured peak of 40 concurrent jobs. Every assumption below is substitutable.
| Input | Value | Source |
|---|---|---|
| Linux job minutes per month | 45,000 | Your workflow run history |
| Job hours | 750 | 45,000 divided by 60 |
| Runner shape | 8 vCPU, 32 GB | Your runs-on labels |
| Managed rate for that shape | $0.016 per minute | warp-ubuntu-latest-x64-8x on the pricing page |
| BYOC WarpBuild fee | $0.002 per minute | Pricing page |
| Example on-demand instance rate | $0.40 per hour | Substitute your rate from AWS EC2 On-Demand pricing |
| Example managed Kubernetes control plane rate | $0.10 per cluster hour | Substitute the current rate from Amazon EKS pricing |
| Self-managed pool utilization | 50 percent | Your own fleet metrics |
| Boot and teardown overhead on per-job instances | 10 percent | Your own instance start times |
The arithmetic, compute lines only:
- Managed runners. 45,000 minutes times $0.016 equals $720.00. Nothing else appears.
- BYOC on-demand. Instances bill while jobs run, so 750 job hours plus 10 percent overhead equals 825 instance hours, times $0.40 equals $330.00. The WarpBuild fee is 45,000 times $0.002 equals $90.00, for $420.00.
- BYOC on spot. BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices. AWS discount checked on 2026-08-13. At the published ceiling against the example rate, $0.04 per hour times 825 hours equals $33.00, for $123.00.
- Self-managed pool. 750 job hours at 50 percent utilization equals 1,500 instance hours, times $0.40 equals $600.00, plus a control plane at $0.10 for 720 hours equals $72.00, for $672.00 before a minute of engineering time.
| Shape | Compute and fees | What it excludes |
|---|---|---|
| Self-managed pool at 50 percent utilization | $672.00 | Image builds, autoscaler tuning, upgrades, on-call |
| Managed runners | $720.00 | Nothing |
| BYOC on-demand | $420.00 | Object storage in your stack, cloud account admin |
| BYOC on spot at the AWS published ceiling | $123.00 | Object storage in your stack, cloud account admin |
The utilization the pool has to hit
Set the self-managed line equal to the BYOC on-demand line and solve for utilization. The pool needs 750 job hours divided by U instance hours at $0.40, plus $72.00 of control plane, to reach $420.00. That gives U of about 86 percent, and it holds only while the engineering time is priced at zero. A pool absorbing a peak of 40 concurrent jobs against an average of 1.04 has to be extremely good at shrinking to get near that. The payroll side of the same question, with hours priced, is worked out in what self-hosted runners actually cost.
Two structural notes before this model reaches a spreadsheet. Re-check the AWS links before quoting the totals internally; cloud list prices move, and the utilization break-even is the part of this page that stays stable.
FAQ
What does scaling self-hosted GitHub Actions runners actually require?
Four subsystems that have to keep working at the same time. Registration and deregistration against a repository, organization, or enterprise scope; an autoscaler driven by queue depth; a runner image rebuilt on GitHub's 30-day software update clock; and cleanup that removes the machine and its registration record after every job. Dropping any one of the four turns into stalled queues, drifting toolchains, or leaked instances.
What does the middle path cost?
BYOC runners cost $0.002 per minute in WarpBuild fees and the compute is billed by your own cloud account, where AWS states Spot Instances run at up to a 90 percent discount compared to On-Demand prices. AWS discount checked on 2026-08-13. At 45,000 Linux job minutes per month and an example on-demand rate of $0.40 per hour, that is $90.00 in fees plus $330.00 of instance time, against $720.00 for the same minutes on warp-ubuntu-latest-x64-8x at $0.016 per minute.
How busy does a self-managed pool have to be to beat that?
About 86 percent utilization at the example rates in the model above, before any engineering time is counted. A pool sized to absorb a peak of 40 concurrent jobs while the month averages 1.04 concurrent jobs sits far below that, which is why utilization rather than the instance rate decides whether a pool is worth operating.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.