GitHub Actions #8 Self-Hosted Runners: When to Adopt, Registration, and Security Principles

4 min read

Every job so far has run on runs-on: ubuntu-latest — a runner operated by GitHub. It is an excellent default that needs zero management, but there are three kinds of situations it cannot cover. The answer in those cases is to operate runners yourself, with self-hosted runners, and this part covers everything from the adoption decision to the security principles. To put the conclusion first: this is a tool you adopt only when a real need exists. Operating runners is operating servers, and adopting them without a reason only adds management burden.

When to adopt: three real needs #

  1. Internal network access: pipelines that must reach resources inside your corporate network (internal APIs, private databases, an in-house artifact repository) are simply unreachable from hosted runners. Placing a runner inside the internal network is the straightforward answer.
  2. Cost: as we saw in #1, hosted runners on private repositories bill by the minute. For organizations with heavy usage there is a break-even point where self-hosted runners on always-on servers (or spot instances) become cheaper. Conversely, with light usage, hosted runners are overwhelmingly cheaper — the server bill arrives even while your runner sits idle.
  3. Special specs: ML jobs that need GPUs, builds that need large memory or disk, specific OSes or architectures — specs the hosted lineup does not offer.

If none of these three applies to you, read this part as a map for someday and stay on hosted runners.

Registration and routing: labels are the address #

The registration procedure itself is simple. In the Runners menu under the repository (or organization) Settings, click New self-hosted runner and you are given the download and registration commands for the runner agent. Run them as-is on the target machine, and the runner connects to GitHub and goes into a waiting state. Registering it as a service so it starts automatically on boot completes the basic installation.

On the workflow side, you target runners with labels in runs-on.

.github/workflows/build.yml
jobs:
  build:
    runs-on: [self-hosted, linux, gpu]

A runner gets default labels at registration (self-hosted, OS, architecture), and you can add custom labels (gpu, internal, and so on). Only a runner that has every label you list in runs-on receives the job, so label design is job routing design. Settle on purpose-based labels — “GPU jobs go to gpu-labeled runners, internal-network deploys go to internal-labeled runners” — and your workflows stay unchanged as runners multiply.

The security principle: never on public repositories #

The most important principle of self-hosted runners is this: do not connect a self-hosted runner to a public repository that accepts fork PRs. The reason is structural. A PR’s workflow runs the PR’s code, and anyone can open a fork PR against a public repository. That opens a path where a stranger’s code executes on your runner machine — and if the runner sits inside your internal network, it executes inside your internal network. GitHub-hosted runners are isolated VMs discarded after every run, which makes this an acceptable risk; your own server is different. It is safest to remember self-hosted as private-repository-only, and the other half of this topic (the pull_request_target trap) comes in the next part.

In the same vein, runner lifetime has its own principle. Use a runner machine long enough and the state that jobs leave behind (caches, credential leftovers, tool versions) piles up and becomes the cause of “the build that worked yesterday.” Where possible, run ephemeral runners — runners that process one job and are then discarded — to reproduce the cleanliness of hosted runners. That is the current best practice.

At scale: actions-runner-controller #

Once you are past a handful of runners and need them to grow and shrink with demand, the standard answer is actions-runner-controller (ARC), the official open source project that manages runners as Pods on Kubernetes. It automatically scales runner Pods up and down to match the number of queued jobs, and the ephemeral runner principle falls out naturally from the Pod lifecycle. It does assume you operate Kubernetes, which is a real barrier to entry, so the time to adopt is when a team that knows the K8s track starts to tire of managing runner counts by hand.

Recap #

What we covered in this part:

  • The adoption criteria are three: internal network access, a usage-based cost break-even, and special specs. If none applies, staying on hosted runners is the right call
  • Registration is done with the commands Settings walks you through, and the label combination in runs-on is your job routing
  • Never connect to a public repository that accepts fork PRs — it is a path for unfamiliar code to run on your machines
  • Ephemeral operation is the best practice for runners. Long-lived runners accumulate state and create reproducibility problems
  • At scale, auto-scale with actions-runner-controller on Kubernetes

In the next part (#9 Security Hardening), we tackle workflow security head-on: minimizing GITHUB_TOKEN permissions, SHA-pinning third-party actions, and the pull_request_target trap.

X