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.

QuantityArithmeticResult
Job hours in the month45,000 divided by 60750 hours
Hours in the month30 times 24720 hours
Average concurrent jobs750 divided by 7201.04
Peak concurrent jobsMeasured from queue depth40
Standing pool sized for peak40 times 72028,800 instance hours
Utilization of that pool750 divided by 28,8002.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 doneSelf-managed poolManaged runnersBYOC
Runner registration and deregistrationYoursOperated for youOperated for you
Autoscaling policy and queue-depth signalYoursOperated for youOperated for you
Runner image build and 30-day rebuild clockYoursOperated for youOperated for you
Per-job cleanup and isolationYoursOperated for youOperated for you
Control plane upgradesYoursOperated for youApplied through the dashboard when a stack update appears
Cloud account, IAM, and quota headroomYoursNone to runYours
Region choiceYoursManagedYours, fixed per stack at creation
Who pays for the computeYour cloud invoiceInside the per-minute rateYour cloud invoice
Spot instances and custom base imagesYours to buildManagedAvailable on your instances

Sources: the BYOC documentation and the cloud runners catalog, both checked on 2026-08-13.

The decision, in order

  1. 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.
  2. 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.
  3. 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 integration

The 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:

  1. 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.
  2. Name stacks by product and region. The stack name, object storage location, and region cannot be changed after creation, so payments-use1 ages better than stack-1.
  3. 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.

InputValueSource
Linux job minutes per month45,000Your workflow run history
Job hours75045,000 divided by 60
Runner shape8 vCPU, 32 GBYour runs-on labels
Managed rate for that shape$0.016 per minutewarp-ubuntu-latest-x64-8x on the pricing page
BYOC WarpBuild fee$0.002 per minutePricing page
Example on-demand instance rate$0.40 per hourSubstitute your rate from AWS EC2 On-Demand pricing
Example managed Kubernetes control plane rate$0.10 per cluster hourSubstitute the current rate from Amazon EKS pricing
Self-managed pool utilization50 percentYour own fleet metrics
Boot and teardown overhead on per-job instances10 percentYour 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.
ShapeCompute and feesWhat it excludes
Self-managed pool at 50 percent utilization$672.00Image builds, autoscaler tuning, upgrades, on-call
Managed runners$720.00Nothing
BYOC on-demand$420.00Object storage in your stack, cloud account admin
BYOC on spot at the AWS published ceiling$123.00Object 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.