Skip to content

Self-Hosted GitHub Actions Runners: Setup, Labels, Groups and Autoscaling

Lesson 5 of 11Advanced5 min readGitHub Actions & CI/CD · Advanced ActionsVerified: GitHub Actions runner application and runner groups, August 2026

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.

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.
  1. In the repository, organisation or enterprise, go to Settings → Actions → Runners → New self-hosted runner and choose the platform.

  2. Run the download and configure commands shown. The registration token is short-lived, single-use, and specific to that scope.

  3. Start the runner. run.sh runs it in the foreground; svc.sh install && svc.sh start installs it as a service.

  4. Confirm it appears as Idle in the runners list.

jobs:
build:
runs-on: self-hosted

self-hosted is applied automatically to every self-hosted runner. On its own it means “any of our machines”, which is rarely specific enough.

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 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.

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.

Terminal window
./config.sh --url https://github.com/OWNER/REPO --token TOKEN --ephemeral

An 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.

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-set

Each 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.

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.

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.

  1. Register a self-hosted runner on a disposable VM against a private test repository.

  2. Add a custom label and target it with runs-on: [self-hosted, your-label]. Confirm the job runs.

  3. Deliberately misspell the label. Confirm the job queues rather than failing, and note how long it would have waited.

  4. 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.

  5. Re-register with --ephemeral. Repeat step 4 and confirm the file is gone.

  6. Read secure self-hosted runners before putting one anywhere near production.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.