Self-Hosted Runners and Faster Workflows
When to run your own GitHub Actions runners, how to register and target them safely with labels and ephemeral mode, autoscaling with ARC, and the tuning that makes any workflow faster.
Every GitHub Actions workflow runs on a runner - a machine that checks out your code and executes the steps. By default GitHub gives you fresh, disposable virtual machines it manages for you, and for most projects that is the right answer. But sometimes those machines are too small, too slow, or cannot reach the thing you need to talk to. That is when you reach for a self-hosted runner: a machine you own, running the same runner agent, that GitHub sends jobs to. This guide covers when self-hosting is worth it, how to register and target runners safely (there is a security trap here that has bitten a lot of people), how to autoscale them, and a practical set of tuning knobs that make any workflow - hosted or self-hosted - noticeably faster.
GitHub-hosted vs self-hosted: what you are actually choosing
A GitHub-hosted runner is a clean VM that GitHub spins up for each job, runs your steps on, and throws away. You pay per minute (free minutes on public repos and a monthly allowance on private ones), you get a curated image with common tools preinstalled, and you never patch or secure the machine. The tradeoff is that you take what GitHub gives you: a fixed set of machine sizes, no special hardware, and no line of sight into your private network.
A self-hosted runner is a machine you provide - a VM, a bare-metal box, a container, whatever - running GitHub's runner agent. It connects out to GitHub, polls for jobs targeted at it, and runs them on your hardware. You own the whole thing: the OS, the installed tools, the security, the uptime.
You self-host when the hosted runners cannot do the job or the economics stop making sense:
- Bigger machines. A build that needs 32 cores and 128 GB of RAM, or a huge disk for a monorepo, will crawl on a standard hosted runner. Your own box can be as large as you like. (GitHub now sells larger hosted runners too, so price this against self-hosting before you commit.)
- Special hardware. GPUs for ML training or CUDA builds, ARM boards for cross-compilation, an actual macOS machine you control, or a device you plug in for firmware tests. Hosted runners will not give you any of that.
- Access to a private network. If your tests need to reach a database, an internal package registry, or a staging environment that lives inside your VPC and is not exposed to the internet, a runner inside that network can reach it directly. A hosted runner cannot without you punching holes in your firewall.
- Cost at scale. Per-minute hosted pricing is convenient until you are running thousands of build-hours a month. At high, steady volume, a pool of your own machines (or autoscaled spot instances) can be dramatically cheaper. Below that volume, self-hosting rarely pays for the maintenance it adds.
The honest default: start on hosted runners. Move to self-hosted only when you hit one of the reasons above, because you are taking on real operational and security work in exchange.
Registering a runner and targeting it with labels
Registration is straightforward. In the repo (or org, or enterprise) settings under Actions -> Runners, "New self-hosted runner" gives you a token and a few commands. On the machine you want to enlist, you download the agent, configure it against your repo URL and token, and start it:
# on the machine that will run jobs
mkdir actions-runner && cd actions-runner
curl -o actions-runner.tar.gz -L https://github.com/actions/runner/releases/download/v2.x.x/actions-runner-linux-x64.tar.gz
tar xzf actions-runner.tar.gz
# register against your repo (token comes from the Runners settings page)
./config.sh --url https://github.com/your-org/your-repo --token YOUR_TOKEN --labels gpu,linux,cuda
# run it (or install it as a service so it survives reboots)
./run.sh
Once it is up, the runner appears in the Runners list as "Idle" and starts polling for work. The --labels you gave it are how workflows find it. Every runner automatically carries labels for its OS and architecture (self-hosted, linux, x64), and you add your own custom ones (gpu, bigmem, staging-net) to describe what the machine can do.
In a workflow, runs-on selects a runner by matching labels. When you list several, the job needs a runner that has all of them:
jobs:
train:
# picks a self-hosted runner that has ALL of these labels
runs-on: [self-hosted, linux, gpu]
steps:
- uses: actions/checkout@v4
- run: python train.py
build:
# still a GitHub-hosted runner - self-hosting is opt-in per job
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make build
The key habit: always include the self-hosted label (and ideally an OS label) in your runs-on for jobs meant for your machines, and use a specific custom label for special hardware. If you write runs-on: self-hosted and have a mixed pool, a GPU job might land on a runner with no GPU. Labels are the only contract between the job and the machine, so make them describe capabilities precisely.
The security warning: do not use self-hosted runners on public repos
This is the single most important thing in this guide, so it gets its own section.
Do not attach self-hosted runners to a public repository. GitHub's own docs say this in bold, and the reason is simple and dangerous. On a public repo, anyone can open a pull request, and by default a PR from a fork can trigger workflows. That means an attacker can submit a PR whose only purpose is to run malicious code - and if your workflows run on your self-hosted machine, that untrusted code executes on your infrastructure. Because a self-hosted runner is a real machine on your network, that is a direct path to your internal systems, cached credentials, and anything else the runner can reach. Hosted runners survive this because each one is a fresh throwaway VM with no access to your network; a self-hosted runner is the opposite.
If you genuinely must run self-hosted runners in a context that touches public contributions, treat it as a hostile environment:
-
Use ephemeral runners. Configure the runner to accept exactly one job and then remove itself, so no state, credentials, or malicious leftovers survive between jobs. You get this with the
--ephemeralflag at registration time, and it is the foundation autoscaling is built on anyway../config.sh --url https://github.com/your-org/your-repo \ --token YOUR_TOKEN --labels linux,ephemeral --ephemeral -
Require approval for fork PRs. In the repo's Actions settings, require a maintainer to approve workflow runs from first-time or all outside contributors, so nothing runs on your hardware without a human looking first.
-
Isolate the runner. Run it in a locked-down VM or container with no standing access to secrets or internal services, and tear it down after each job.
For private repos, the risk is much lower because only people you trust can trigger runs - but ephemeral runners are still the recommended default, because a clean machine per job removes an entire class of "the last job left something behind" bugs.
Autoscaling with Actions Runner Controller
A static pool of self-hosted machines has an obvious problem: sized for peak load they sit idle (and cost money) most of the time; sized for average load, jobs queue up during a busy afternoon. The fix is to scale the pool with demand, and the standard way to do that is Actions Runner Controller (ARC) running on Kubernetes.
At a high level, ARC turns "a runner" into "a Kubernetes workload." You install the controller into a cluster and define a runner scale set - a description of the runners you want (their labels, their container image, resource requests). ARC then watches GitHub's job queue for that scale set: when jobs are waiting, it creates new runner pods; when jobs finish, it removes them. Each runner pod is ephemeral by design - it registers, runs one job, and is deleted - which cleanly solves both scaling and the leftover-state problem at once.
The payoff is that your CI capacity becomes elastic and Kubernetes-native. You scale to zero when nobody is building (no idle cost), burst to dozens of runners during a release, and manage the whole thing with the same tools, autoscaling, and node pools (including GPU or spot nodes) you already use for other workloads. You do not hand-register machines or babysit a fixed fleet - you declare the desired shape of the pool and the controller reconciles it. The details of installing ARC are their own topic, but the mental model is the important part: ephemeral runner pods, created on demand from a queue, torn down after one job.
Making any workflow faster
Faster runners help, but most slow pipelines are slow because of how the workflow is written, not the hardware under it. These knobs apply whether you are on hosted or self-hosted runners, and they usually beat throwing bigger machines at the problem.
Cache dependencies. Re-downloading and rebuilding dependencies on every run is the most common source of wasted minutes. Cache them keyed on your lockfile, so an unchanged lockfile restores instantly instead of resolving from scratch. This alone often halves a build. For the full treatment - cache keys, restore-keys, and the difference between caches and artifacts - see the caching and artifacts guide.
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: npm-
Right-size the machine. More cores only help if your build actually parallelizes. A single-threaded test suite runs no faster on 32 cores than on 4, so measure before you upsize - and on hosted runners, downsize jobs that do not need the power to save minutes. Match the machine to the workload instead of defaulting to the biggest thing available.
Cancel superseded runs with concurrency. When you push three times in a row, you do not need CI to finish the first two - they are already obsolete. A concurrency group keyed on the branch cancels the in-progress run when a newer one starts, freeing runners and getting you feedback on the latest commit sooner.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Skip work with path filters. A docs-only change should not run the full test matrix. Trigger jobs only when relevant files change, so unrelated edits do not pay for work they do not need.
on:
push:
paths:
- 'src/**'
- 'package-lock.json'
Split slow jobs so they run in parallel. One long job is a single lane; several smaller jobs run concurrently across runners. Break a 30-minute monolith into lint, unit, and integration jobs and the wall-clock time drops to the longest single job, not the sum. A matrix is the easy way to fan out the same job across versions or shards:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/4
Set timeouts so a hung job cannot run forever. Without a timeout, a wedged job can burn the full six-hour default before it is killed - wasting minutes and, on self-hosted runners, tying up a machine. Cap every job at a sane ceiling.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
None of these are exotic. Cache, right-size, cancel superseded runs, filter by path, split and parallelize, and cap with timeouts - apply them together and most pipelines get noticeably faster and cheaper without touching the runner hardware at all.
The shape of it
Hosted runners are the right default; self-host only when you need a bigger machine, special hardware like GPUs, access to a private network, or cost relief at real scale. Register a runner, give it descriptive labels, and target those labels with runs-on - listing several means the job needs all of them. Never attach a self-hosted runner to a public repo, because an untrusted PR can then run code on your infrastructure; use ephemeral runners (one job, then gone) as the baseline everywhere, and lean on ARC to autoscale ephemeral runner pods on Kubernetes when demand is spiky. Then, whatever the runner, make the workflow itself fast: cache dependencies, right-size the machine, cancel superseded runs, filter by path, split slow jobs to run in parallel, and set timeouts. The hardware matters least; how you write the workflow matters most.