A self-hosted runner is a machine you own that polls GitHub for jobs and runs them. It gives you hardware GitHub does not offer, access to private networks, and control over cost at scale.
It also makes you responsible for a machine that executes code from your repositories, and the security properties are genuinely different from hosted runners in ways that are easy to underestimate.
When they are justified
Section titled “When they are justified”Good reasons:
- Hardware GitHub does not offer — more RAM than the largest hosted runner, specialised accelerators, a specific CPU feature, a physical device attached for hardware-in-the-loop testing.
- Private network access — a build that must reach an internal artifact repository, a database, or a licence server that will not be exposed to the internet.
- Licensed software that is node-locked to a machine.
- Sustained high volume, where the arithmetic genuinely favours owning capacity.
Weak reasons, in practice:
- “Hosted runners are slow.” Often the fix is caching or a larger hosted runner, not owning machines.
- “It’ll be cheaper.” The runner minutes are only part of the cost. Patching, monitoring, image maintenance, disk exhaustion at 2am and the security work below are the rest.
Installing one
Section titled “Installing one”-
In the repository, organisation or enterprise, go to Settings → Actions → Runners → New self-hosted runner and choose the platform.
-
Run the download and configure commands shown. The registration token is short-lived, single-use, and specific to that scope.
-
Start the runner.
run.shruns it in the foreground;svc.sh install && svc.sh startinstalls it as a service. -
Confirm it appears as Idle in the runners list.
jobs: build: runs-on: self-hostedself-hosted is applied automatically to every self-hosted runner. On its own it means “any of our
machines”, which is rarely specific enough.
Labels
Section titled “Labels”runs-on: [self-hosted, linux, x64, gpu]An array means every label must match — it is an AND, not a preference list. A job requesting a label no runner has does not fail; it queues, indefinitely, showing as waiting for a runner. That is the single most common self-hosted support question, and it is almost always a typo in a label.
Give runners meaningful labels beyond the automatic self-hosted, linux/windows/macOS and
x64/ARM64: the hardware class, the network zone, the image version.
Runner groups
Section titled “Runner groups”Runner groups control which repositories may use which runners. Without them, at organisation scope, any repository can schedule onto any runner — including a machine with production network access.
Create groups by trust and capability, and restrict each to named repositories. Availability of runner groups and the granularity of their access controls depends on the account’s plan and whether runners are registered at organisation or enterprise level, so check what your organisation actually has rather than assuming.
Ephemeral runners
Section titled “Ephemeral runners”The default runner is persistent: it runs a job, keeps its filesystem, and picks up the next one. That
is fast and it means state leaks between jobs — a modified global config, a left-behind credential
file, a poisoned dependency cache, a ~/.npmrc written by one repository and read by another.
./config.sh --url https://github.com/OWNER/REPO --token TOKEN --ephemeralAn ephemeral runner accepts exactly one job, then deregisters. Combined with a fresh VM or container per registration, that gives you the clean-slate property hosted runners have by default.
Ephemeral runners are the right default for anything beyond a single trusted team’s machine. They are only practical with automation to replace them, which is what the controller below provides.
Autoscaling
Section titled “Autoscaling”Running a fixed fleet means paying for idle capacity and queueing at peak. The Actions Runner Controller runs on Kubernetes, listens for queued jobs, and creates ephemeral runner pods to match:
jobs: build: runs-on: arc-runner-setEach job gets a fresh pod that is destroyed afterwards. This is the configuration that makes self-hosting defensible at scale — ephemeral by construction, scaled to demand, and described as code rather than as a set of hand-configured VMs.
The trade-offs are real: you are now operating a Kubernetes controller, pod startup adds latency to every job, and anything you wanted to cache locally is gone with the pod. That last point often reintroduces the slowness that motivated self-hosting in the first place, so plan for a shared cache.
Operational realities
Section titled “Operational realities”Disk exhaustion is the most common failure. Workspaces, Docker images and build caches accumulate, and a full disk produces baffling errors. Prune on a schedule and alert on free space.
The runner auto-updates by default. That is usually what you want; if you pin the version with
--disableupdate, you own keeping it current, and a runner too far behind stops being able to
connect.
Concurrency is one job per runner unless you install multiple runner services on the machine. Two runners on one host share CPU, disk and any port a test binds — which is a common source of flakiness.
Secrets in the environment persist on a non-ephemeral runner exactly as long as whatever wrote
them. A workflow that writes a kubeconfig to ~/.kube/config has left it there for the next job.
Cost, honestly
Section titled “Cost, honestly”The comparison people make is hosted per-minute pricing against the hourly cost of a VM. The full picture includes:
- The machine, running whether or not jobs are queued, unless you autoscale.
- Storage, egress and the network path to GitHub.
- The Kubernetes cluster if you autoscale.
- Engineering time: image builds, patching, the controller, disk alerts, incident response.
- The security work — isolation, runner groups, ephemerality, monitoring.
Self-hosting usually wins on sustained high-volume workloads and on jobs that genuinely need non-standard hardware. It usually loses on “our CI feels slow”, which is more often a caching problem.
Exercise
Section titled “Exercise”-
Register a self-hosted runner on a disposable VM against a private test repository.
-
Add a custom label and target it with
runs-on: [self-hosted, your-label]. Confirm the job runs. -
Deliberately misspell the label. Confirm the job queues rather than failing, and note how long it would have waited.
-
Run a job that writes a file to the home directory. Run a second, different workflow and read the file. That is the state-leakage problem in one experiment.
-
Re-register with
--ephemeral. Repeat step 4 and confirm the file is gone. -
Read secure self-hosted runners before putting one anywhere near production.