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

Isolating GitHub Actions runners

35 min de lecture

Read this page in French

Runner isolation lets you control which workflows may run on which machines. It is essential for security and for resource management in large organisations.

What you will learn

  • Understand why runners have to be isolated (security, performance, compliance)
  • Route jobs with labels and runner groups
  • Apply the isolation patterns: by environment, by trust, by team
  • Control access at repository or organisation level
  • Segment the network to partition the runners

Why isolate runners?

Without isolation, a fleet of self-hosted runners forms a single pool: any job of the repository or of the organisation can land on any machine. Three categories of problems follow, and they are not handled at the same level: the first one is about security, the second about capacity, the third about traceability.

Security

A machine running both a PR job and a production deployment gives the former access to the resources of the latter: internal network, cached tokens, built images. Separating runners means deciding in advance what a job of medium trust is able to reach.

  • Prevent untrusted PRs from reaching the production network
  • Limit the blast radius of a compromised workflow
  • Honour the principle of least privilege

Performance

Jobs are spread over the runners carrying the requested labels, with no notion of priority: one team's test queue can therefore block another team's deployment. Dedicating machines per use guarantees reserved capacity for what cannot wait.

  • Reserve powerful runners for critical builds
  • Avoid contention between teams
  • Guarantee different SLAs per project

Compliance

Some constraints are not satisfied by good intentions: production data that must never transit through a development machine, processing that must stay inside a given geographical zone. Tying a job to an identified machine is what makes the demonstration possible during an audit.

  • Separate sensitive data by environment
  • Trace runs per team
  • Respect geographical zones

Labels and runner groups

GitHub offers two complementary levers that should not be confused. Labels do routing: they express what a job asks for and determine the machine that will run it. Runner groups do access control: they determine which repositories are allowed to use those machines. A label on its own forbids nothing, any workflow of the repository can write it into its runs-on.

Custom labels

Labels identify the capabilities of a runner: a job is only dispatched to the machines carrying all the labels it asks for. They are declared at registration, but remain editable afterwards from the repository or organisation settings.

Fenêtre de terminal
# At registration
./config.sh --url https://github.com/ORG/REPO \
--token TOKEN \
--labels linux,x64,docker,gpu,production

Use inside a workflow:

jobs:
build:
runs-on: [self-hosted, linux, docker]
deploy-prod:
runs-on: [self-hosted, production]
ml-training:
runs-on: [self-hosted, gpu]

Runner groups (organisation)

At organisation level, groups let you restrict access: a runner belongs to exactly one group, and the group lists the allowed repositories. It is the only mechanism that really prevents a repository from sending a job to a machine, where labels merely steer it.

  1. Go to Settings > Actions > Runner groups
  2. Create a group (for instance production-runners)
  3. Assign runners to the group
  4. Define which repositories may use it
# Only the allowed repositories can use this runner
jobs:
deploy:
runs-on:
group: production-runners
labels: [linux, x64]

Isolation patterns

Three splits cover most needs. They combine: one runner can be production, team-platform and gpu at the same time.

By environment

The most common split maps a set of runners to every stage of the delivery chain. The point is not the label itself but what it implies behind it: each group of machines lives in the network of its environment and only reaches the matching databases and APIs. The environment: key adds a second barrier, on the GitHub side this time, with its own approval rules and its own secrets.

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Dev Runners │ │ Staging Runners │ │ Prod Runners │
│ │ │ │ │ │
│ Labels: │ │ Labels: │ │ Labels: │
│ - dev │ │ - staging │ │ - production │
│ - self-hosted │ │ - self-hosted │ │ - self-hosted │
│ │ │ │ │ │
│ Access: all │ │ Access: QA team │ │ Access: ops │
└─────────────────┘ └──────────────────┘ └─────────────────┘
jobs:
test:
runs-on: [self-hosted, dev]
staging:
runs-on: [self-hosted, staging]
environment: staging # Extra protection
production:
runs-on: [self-hosted, production]
environment: production

By level of trust

This split classifies jobs by the origin of the code they run: a push to main has been reviewed and merged, a PR has not yet. The untrusted runners then only get outbound internet access, with no route to the internal network and no production secrets. The ternary expression in runs-on makes the choice when the job starts, from github.event_name.

┌─────────────────────┐ ┌─────────────────────┐
│ Trusted Runners │ │ Untrusted Runners │
│ │ │ │
│ For: │ │ For: │
│ - Pushes to main │ │ - Internal PRs │
│ - Release tags │ │ - Test branches │
│ │ │ │
│ Access: │ │ Access: │
│ - Internal network │ │ - Internet only │
│ - Sensitive secrets │ │ - Limited secrets │
└─────────────────────┘ └─────────────────────┘
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
# Trusted code (push) goes to a trusted runner; a PR goes to an untrusted one
runs-on:
- self-hosted
- ${{ github.event_name == 'push' && 'trusted' || 'untrusted' }}

This pattern is for private repositories

An untrusted runner reduces what a low-trust job can reach, it does not make it harmless: the code still runs on your machine, with your kernel and your outbound network access.

On a public repository, that level of reduction is not enough, and the rule stated in Securing the runners applies without exception: no self-hosted runner serves a public repository, nor the pull requests of forks. The trusted and untrusted split answers a different need, partitioning internal contributions by their degree of review.

By team

This third axis answers a need for capacity and for chargeback rather than for security: each team gets its own machines and does not suffer the queue of the others. The labels stack with those of the previous axes, a job being able to ask for both its team and a specific piece of hardware.

jobs:
frontend-build:
runs-on: [self-hosted, team-frontend]
backend-build:
runs-on: [self-hosted, team-backend]
ml-training:
runs-on: [self-hosted, team-data, gpu]

Controlling access to the runners

The registration level of a runner determines who can use it. The wider the scope, the stricter the access control must be.

Repository level

A runner registered at repository level only appears in that repository: no other project can send it a job, even by guessing its labels. At the price of an under-used machine to maintain for a single project, such a runner:

  • Is only reachable by that repository
  • Offers maximum isolation
Fenêtre de terminal
./config.sh --url https://github.com/OWNER/REPO ...

Organisation level

A runner registered at organisation level is shared, which improves its occupancy rate but widens its exposure surface just as much. The group is no longer optional there, it becomes the boundary deciding which repositories can reach the machine. Such a runner:

  • Can be shared between repositories
  • Is controlled by the groups
Fenêtre de terminal
./config.sh --url https://github.com/ORG ...

Workflow permissions

Use environments to add a control layer: a GitHub environment carries its own secrets, its protection rules and, if you enable it, a manual approval before the job starts. The job stays pending as long as the approval is not given, and the secrets only reach the runner at that moment.

jobs:
deploy:
runs-on: [self-hosted, production]
environment:
name: production
# Requires manual approval

Network and firewall

Isolation through labels and groups happens on the GitHub side: it decides where a job goes. It says nothing about what the machine can reach once the job has started. A runner badly placed in the network stays reachable from the job, however clean the labels are. Network partitioning is therefore the layer that makes logical isolation effective.

Network segmentation

Each environment occupies its own subnet, and a runner only sees the services of its own. The dev job trying to reach the production database fails at the network level, without depending on a correct workflow configuration.

┌─────────────────────────────────────────────────────────────┐
│ VPC / Network │
├─────────────────┬─────────────────┬─────────────────────────┤
│ Subnet Dev │ Subnet Staging │ Subnet Production │
│ │ │ │
│ ┌─────────────┐ │ ┌─────────────┐ │ ┌─────────────────────┐ │
│ │ Dev Runner │ │ │Staging Runner│ │ │ Prod Runner │ │
│ │ │ │ │ │ │ │ │ │
│ │ Access: │ │ │ Access: │ │ │ Access: │ │
│ │ - DB dev │ │ │ - DB staging│ │ │ - DB prod │ │
│ │ - API dev │ │ │ - API staging│ │ │ - K8s prod │ │
│ └─────────────┘ │ └─────────────┘ │ └─────────────────────┘ │
└─────────────────┴─────────────────┴─────────────────────────┘

Firewall rules per label

The rules are built as an allow list: you permit the useful subnet and port 443 towards GitHub, then drop the rest. The final DROP is what limits exfiltration if a job is compromised. Check that the package mirrors and the image registries stay reachable, otherwise the first builds will fail on network timeouts.

Fenêtre de terminal
# Dev runner: limited access
iptables -A OUTPUT -d 10.0.1.0/24 -j ACCEPT # Dev subnet
iptables -A OUTPUT -d 0.0.0.0/0 -p tcp --dport 443 -j ACCEPT # GitHub
iptables -A OUTPUT -j DROP
# Production runner: wider but controlled access
iptables -A OUTPUT -d 10.0.0.0/16 -j ACCEPT # The whole VPC
iptables -A OUTPUT -d 0.0.0.0/0 -p tcp --dport 443 -j ACCEPT
iptables -A OUTPUT -j DROP

A complete architecture example

This workflow puts the patterns of the guide end to end: tests on shared runners, build on team runners, deployments on per-environment runners with an approval for production.

.github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
# No rights by default: every job asks for the minimum
permissions: {}
jobs:
# Tests on shared runners
test:
runs-on: [self-hosted, linux, shared]
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: npm test
# Build on the team runners
build:
needs: test
runs-on: [self-hosted, linux, team-platform]
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: docker build -t app .
# Staging deployment (staging network)
deploy-staging:
if: github.ref == 'refs/heads/develop'
needs: build
runs-on: [self-hosted, staging-network]
environment: staging
steps:
- run: kubectl --context staging apply -f k8s/
# Production deployment (production network, approval required)
deploy-production:
if: github.ref == 'refs/heads/main'
needs: build
runs-on:
group: production-runners
labels: [linux, x64]
environment:
name: production
url: https://app.company.com
steps:
- run: kubectl --context production apply -f k8s/

Good practices

A runner architecture degrades quickly: machines are added as needs arise, labels multiply and nobody knows any more what a given runner does. These four habits keep the fleet understandable and, above all, auditable.

1. Name the labels clearly

A label is the only information the author of a workflow has to choose a machine: it must describe a capability or a scope, not a serial number. A vague name such as fast ends up designating the slowest machine of the fleet, without anyone daring to fix it.

Fenêtre de terminal
# ✅ Explicit labels
--labels linux,x64,docker,production,team-platform
# ❌ Vague labels
--labels runner1,fast

2. Document the architecture

The GitHub interface shows the runners and their labels, never the intention behind the split nor the associated network rules. Without a reference document, the first question asked during an incident or an audit stays unanswered. Keep a document describing:

  • Which runners exist
  • Their labels and capabilities
  • Which repositories and teams may use them
  • The network rules

3. Audit regularly

The gap always widens in the same direction: labels added by hand to unblock a job, never removed afterwards. This query lists the runners of the organisation with their effective labels, to be compared with the reference document.

Fenêtre de terminal
# List the runners and their labels
gh api /orgs/ORG/actions/runners --jq '.runners[] | {name, labels: [.labels[].name]}'

4. Automate the provisioning

A runner configured by hand drifts: packages installed along the way while troubleshooting, a label added then forgotten, a machine impossible to rebuild identically. Describing the provisioning as Infrastructure as Code makes the fleet reproducible and turns a label change into a tracked modification in Git. Three building blocks share the work:

Key points

  • Isolation controls which workflow runs on which machine, a lever for both security and performance.
  • Labels route the jobs; runner groups restrict which repositories may use them.
  • Separate at the very least the trusted runners (pushes, releases) from the untrusted ones (PRs, forks).
  • A runner registered at repository level offers maximum isolation; at organisation level, it is shared through the groups.
  • Complete logical isolation with network segmentation: every environment in its own subnet.

Next steps

  • GitHub CLI (gh): listing the runners, their labels and their groups with gh api, to audit the partitioning you have just put in place.
  • ACT: testing your workflows locally: replaying a workflow on your own machine rather than opening a shared runner to repeated trials.
  • actionlint: declaring your self-hosted labels in .github/actionlint.yaml so that validation stops reporting them as unknown.

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