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.
# At registration./config.sh --url https://github.com/ORG/REPO \ --token TOKEN \ --labels linux,x64,docker,gpu,productionUse 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.
- Go to Settings > Actions > Runner groups
- Create a group (for instance
production-runners) - Assign runners to the group
- Define which repositories may use it
# Only the allowed repositories can use this runnerjobs: 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: productionBy 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
./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
./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 approvalNetwork 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.
# Dev runner: limited accessiptables -A OUTPUT -d 10.0.1.0/24 -j ACCEPT # Dev subnetiptables -A OUTPUT -d 0.0.0.0/0 -p tcp --dport 443 -j ACCEPT # GitHubiptables -A OUTPUT -j DROP
# Production runner: wider but controlled accessiptables -A OUTPUT -d 10.0.0.0/16 -j ACCEPT # The whole VPCiptables -A OUTPUT -d 0.0.0.0/0 -p tcp --dport 443 -j ACCEPTiptables -A OUTPUT -j DROPA 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.
name: CI/CD Pipeline
on: push: branches: [main, develop] pull_request:
# No rights by default: every job asks for the minimumpermissions: {}
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.
# ✅ Explicit labels--labels linux,x64,docker,production,team-platform
# ❌ Vague labels--labels runner1,fast2. 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.
# List the runners and their labelsgh 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:
- Terraform for the VMs
- Helm and Kubernetes for ARC
- Ansible for the configuration
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.yamlso that validation stops reporting them as unknown.