Skip to content
Français
CI/CD & Automatisation medium

Runner groups: bounding what a run can reach

25 min de lecture

Read this page in French

The usual question about a self-hosted runner is "can it be compromised?". The right question is "what can a compromised runner reach?". Runner groups answer the first half, by deciding which repositories use which machines. The network boundary answers the second, and it is what determines the scale of an incident.

What you will learn

  • Understand what a runner group controls, and what it does not
  • Know the real availability depending on your GitHub plan
  • Segment by environment rather than by convenience
  • Think in terms of a network boundary, beyond hardening the machine

What a runner group is

A runner group gathers self-hosted runners and carries an access policy: which repositories of the organisation are allowed to send jobs to them. Without groups, every runner of the organisation is reachable by every repository depending on it, which amounts to treating a prototyping repository like a production one.

Two availability points determine what you can do with them:

  • every organisation has a default group, which exists with no configuration;
  • creating additional groups at organisation level requires at least the GitHub Team plan.

A third point matters more than the previous two: by default, only private repositories reach the runners of a group. That default is prudent, and it can be overridden. GitHub's documentation is explicit about the associated risk, and is worth quoting as it stands:

We recommend that you only use self-hosted runners with private repositories. This is because forks of your public repository can potentially run dangerous code on your self-hosted runner machine by creating a pull request that executes the code in a workflow.

Segment by environment, not by convenience

The spontaneous split follows how teams are organised. The useful split follows the level of trust and the network access.

GroupAllowed repositoriesWhat the machine reaches
runners-devDevelopment repositoriesNothing sensitive, filtered internet egress
runners-buildApplication repositoriesThe internal registry for writing, no production
runners-prodThe single deployment repositoryProduction, deployment secrets

The property you are after is simple to state: a job from a development repository must never run on a machine that reaches production. Without segmentation, a compromised dependency in a side project provides a foothold on the production network, while the production repository itself is beyond reproach.

Targeting is done through the runner labels, on the workflow side:

jobs:
deploy:
runs-on: [self-hosted, linux, x64, prod]

That targeting expresses an intention, it enforces no policy. It is the runner group, on the organisation side, that refuses the job when the repository has no access. A misspelled label leaves the job pending indefinitely rather than routing it to a neighbouring group, which is the desirable behaviour.

The network boundary, what segmentation does not cover

A runner group decides who runs where. It says nothing about what the machine can reach once the job has started. Yet it is that second dimension that sets the cost of an incident.

Ask the question in the order of a real attack:

Who can trigger the workflow?
|
v
Which code runs, and where does it come from?
|
v
On which machine, in which network segment?
|
v
What can that machine reach, outbound and internally?
|
v
Which secrets are present in the environment?
|
v
Which artifact is produced, and who consumes it afterwards?

A self-hosted runner is almost always placed on the internal network, which is its very reason for existing. It therefore inherits access hosted runners do not have: databases, internal registries, administration APIs, hypervisors. Three measures bound that inheritance:

  • Segment. The runner lives on a dedicated network, with explicit filtering rules towards the only destinations it needs. A runner that can reach the whole internal network cancels the benefit of group segmentation.
  • Make it ephemeral. A machine destroyed after every job passes nothing to the next one. It is the only measure that neutralises persistence, the risk specific to self-hosted runners.
  • Filter the outbound traffic. A compromised job needs a channel to exfiltrate. Restricting the allowed destinations turns a leak into a workflow failure, therefore into a visible signal.

Counting the accesses, a ten-minute exercise

The most revealing check needs no tool. For each runner group, list what the machine actually reaches, then compare with what the jobs need.

DestinationNeeded by the jobsReachable from the runner
The internal container registryYes, for writingYes
The internal package repositoryYes, for readingYes
The production databaseNoYes
The hypervisor administration APINoYes

The last two rows are the usual result of a first pass. They do not reflect negligence, but the way runners get provisioned: on the administration network, because that is where the space was. Every bold line is a capability offered free of charge to an attacker who gains a foothold on the machine.

Key points

  • A runner group decides which repositories reach which runners; without groups, every repository shares the same fleet.
  • Every organisation has a default group; creating additional groups requires at least GitHub Team.
  • By default, only private repositories reach the runners of a group, and overriding that default removes the guardrail GitHub puts in place.
  • A public repository and a self-hosted runner do not mix: any pull request becomes arbitrary code execution on your network.
  • Segment by level of trust and network access, not by team: a development job must not run where production is reachable.
  • Labels express an intention on the workflow side; it is the group policy that enforces the refusal.
  • The question that sets the cost of an incident is not "can the runner be compromised" but "what can it reach": segment, make it ephemeral, filter the egress.

Next steps

  • ACT: replaying a workflow locally, which avoids exposing a self-hosted runner for simple trials.
  • actionlint: the linter that flags an unknown runner label, the classic cause of a job waiting forever.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn