Autoscaling Runner
An autoscaling runner is a self-hosted GitHub Actions runner created in response to queued jobs and released when demand falls. How the pattern works.
An autoscaling runner is a self-hosted GitHub Actions runner whose capacity is created in response to queued jobs and released again when demand falls, instead of staying registered around the clock. The name describes a pattern rather than a product: something watches the job queue, adds runners while work is waiting, and takes them away once the queue drains.
Autoscaling is a property of self-hosted fleets. GitHub-hosted runners are provisioned per job by GitHub, so the question of how many machines to keep online only arises once you register runners of your own.
Definition
GitHub's self-hosted runners reference describes autoscaling as dynamically adjusting the number of self-hosted runners based on demand (read on 2026-08-13). Every implementation of that idea has the same three parts.
A queue signal. Something has to learn that jobs are waiting. The workflow_job webhook is available at the repository, organization, and enterprise levels, and its payload carries an action key that follows the stages of a job's lifecycle, so queued is a request for capacity and completed releases it. GitHub notes that webhook-driven scaling depends on the timeliness of webhook delivery, which introduces delay and reliability concerns, and points higher volume fleets at the scale set APIs instead. The reference implementation of those APIs is Actions Runner Controller, whose listener holds an outbound long poll and is told when a job is available.
A provisioning step. A machine boots, carries or installs the runner agent, and registers with the labels the workflow names in runs-on. Registration uses a registration token passed to config.sh or a just-in-time runner configuration created through the REST API for self-hosted runners. Until a runner carrying every requested label is online and idle, the job stays in the queue.
A teardown rule. GitHub recommends autoscaling with ephemeral runners and advises against autoscaling persistent ones, because it cannot guarantee that a persistent runner will be spared a job assignment while it is shutting down. A runner registered with --ephemeral accepts one job and then deregisters, which collapses "job finished" and "capacity released" into a single event.
Four documented numbers bound the pattern. Values below come from GitHub's Actions limits and the self-hosted runners reference, both read on 2026-08-13.
| Documented behavior | Value | Effect on an autoscaler |
|---|---|---|
| Job queue time | 24 hours | A job with no matching runner waits rather than failing, and is cancelled once the window expires |
| Job pickup after assignment | 60 seconds | An assigned runner that does not claim the job in time returns it to the queue for another runner |
| Runners registered at once per runner group | 10,000 | The ceiling on a single group, well above what most queues reach |
| Runner software updates | 30 days | A runner that goes 30 days without a software update stops being queued jobs, so long-lived images need rebuilds |
Scaling to zero and back puts machine boot, image pull, and runner registration in front of the first workflow step. Holding a floor of idle runners moves that wait off the critical path and pays for the idle time instead. That floor is a warm pool, and picking its size is the main tuning decision in any autoscaling setup.
Example
A push to main runs a twelve shard test matrix. Two runners are registered and idle at the moment the push lands.
name: test
on:
push:
branches: [main]
jobs:
unit-tests:
runs-on: [self-hosted, linux, x64]
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
steps:
- uses: actions/checkout@v4
- run: ./run-tests.sh --shard ${{ matrix.shard }} --of 12GitHub expands the matrix into twelve independent jobs, each queued on its own and each visible to the autoscaler as a separate workflow_job event with action set to queued. Two of them start immediately on the idle runners. The other ten are the demand signal.
| Point in the run | Jobs queued | Runners registered | What the autoscaler does |
|---|---|---|---|
| Push lands | 12 | 2 idle | Two shards start, ten queued events stay unmatched |
| Capacity request | 10 | 2 | Requests ten machines from the cloud API or the cluster |
| New runners register | 10 down to 0 | 12 | Each new runner claims one shard |
| Shards finish | 0 | 12 down to 2 | Ephemeral runners deregister after their single job |
| Queue empty | 0 | 2 | No new registrations happen and the fleet sits at its floor |
Each new machine registers with the same labels the job asked for:
./config.sh --url https://github.com/my-org/my-repo \
--token "$RUNNER_REGISTRATION_TOKEN" \
--labels self-hosted,linux,x64 \
--ephemeralThose labels are the contract with the workflow. A job asking for [self-hosted, linux, x64] matches a runner advertising all three, and a runner missing one of them is skipped, so a label typo in either place looks exactly like an autoscaler that failed to scale.
Shrinking is mostly passive in this design. Ephemeral runners remove themselves after their one job, so the scale-in work left over is releasing the underlying machine and keeping the count honest. Counting queued events alone drifts, because jobs get cancelled, fail-fast matrices stop mid-flight, and a job that was not picked up inside 60 seconds is queued a second time. Autoscalers that survive contact with a real repository reconcile against current queue state on a timer instead of trusting the event stream to balance.
Pointing the same matrix at a managed fleet changes one line and nothing about the shape above. The queue signal, the provisioning step, and the teardown rule are unchanged; the pool answering them is one a provider operates instead of one you built:
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-2x
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
steps:
- uses: actions/checkout@v4
- run: ./run-tests.sh --shard ${{ matrix.shard }} --of 12A label of the shape warp-ubuntu-latest-x64-2x is one opaque routing key, so the ten queued events still read as ten capacity requests and the pool still drains back to its floor once the shards finish. What moves is who is holding the queue signal.
Related Terms
- Warm pools and the idle capacity that removes boot latency: the standing floor an autoscaling fleet keeps to hide boot time.
- Actions Runner Controller and how it autoscales runner pods: the Kubernetes implementation of this pattern, with a listener and ephemeral pods.
- Scaling self-hosted runners without owning the cluster: the same queue problem approached without a controller to run.
- WarpBuild cloud runners documentation: the runner labels available and how a workflow selects them.
- BYOC runners documentation: running managed runners inside a cloud account you own.
- WarpBuild pricing: per minute rates by runner type.
FAQ
What makes a runner an autoscaling runner?
Its capacity follows the job queue. A queue signal reports that jobs are waiting, a provisioning step boots machines and registers them with the labels the workflow asks for, and a teardown rule removes them once the work is done. GitHub's self-hosted runners reference describes autoscaling as dynamically adjusting the number of self-hosted runners based on demand.
Why does GitHub recommend ephemeral runners for autoscaling?
Because an ephemeral runner accepts exactly one job and then deregisters, so job completion and capacity release are the same event. GitHub's self-hosted runners reference recommends autoscaling with ephemeral runners and advises against autoscaling persistent runners, since a persistent runner can be assigned a job while it is shutting down.
What happens when the autoscaler is too slow?
The job waits. GitHub does not fail a job that has no matching runner online; it stays queued until a runner carrying every requested label comes online, and GitHub's Actions limits page documents a 24 hour job queue time after which the job is automatically cancelled. A scaling gap therefore reads as a slow queue rather than an error.
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.