Decision guide: label-based self-hosted runner isolation for GitHub Actions
Route GitHub Actions jobs to self-hosted runners using labels, compare runner options, and verify placement with the API.
04 Oct 2025, 09:34 UTC

Decision and constraints
GitHub Actions jobs run on GitHub-hosted runners by default. Those runners are convenient but cannot reach private networks, and billing accrues per minute of job time. Self-hosted runners run on machines you control, which lowers cloud spend for steady workloads and lets jobs access internal services. Without routing controls, every self-hosted job competes for the same pool, so a GPU job can land on a CPU-only machine or a production deploy can share hardware with an untrusted pull request build.
Useful takeaway: Label-based isolation lets you tag each runner with capabilities and route jobs to matching hardware, combining self-hosted cost savings with granular placement control.
Compare supported runner options
| Option | Cost | Management | Isolation | Concurrency | Best for |
|---|---|---|---|---|---|
| GitHub-hosted | Pay-per-minute | None | None per job | Shared | Quick starts, no maintenance |
| Self-hosted permanent | Infrastructure cost | Manual updates | Group-level | Fixed per runner | Stable workloads, long runners |
| Self-hosted ephemeral | Infrastructure cost plus scaling | Auto-terminate | Group or per-job | Dynamic | Security-sensitive, short jobs |
| Label-based routing | Infrastructure cost | Runner labels plus job syntax | Runner-specific | Configurable per label | Targeted infrastructure access |
Trade-offs and when to choose each option
- GitHub-hosted runners require no infrastructure maintenance but cannot run scripts that need private network access or custom binaries. Costs scale with job duration.
- Self-hosted permanent runners fit long-running pipelines or stateful workloads. You own OS updates, security patches, and keeping the host online.
- Self-hosted ephemeral runners scale automatically and terminate after each job, reducing the window for credential leakage. They require orchestration such as Kubernetes or container instances.
- Label-based routing is the most flexible when you already run self-hosted runners and need to direct specific jobs to specific hardware, operating systems, or regions without changing the underlying infrastructure.
Concrete implementation: register, label, and route
Prerequisites
- Repository admin or organization owner permission to create runner registration tokens.
- A host that can reach github.com and the Actions runner API.
- GitHub CLI (gh) or the runner application package.
Step 1: Register a self-hosted runner
Run this on the host that will execute jobs. Replace OWNER/REPO with your repository path. The registration token is single-use and expires after one hour; never commit it to version control.
# Run on the self-hosted runner host
# Required permission: repo admin or org owner
gh auth token
gh actions runner create --repo OWNER/REPO --labels gpu,arm64
Risk: If the token is intercepted before registration, an attacker can add a runner to your repository. Rotate the token in Settings > Actions > Runners if you suspect exposure.
Step 2: Confirm labels in GitHub
Labels are user-defined tags attached to a runner. In workflow YAML, runs-on selects runners by label. Multiple labels separated by commas use OR logic; plus signs use AND logic.
Open Settings > Actions > Runners in the repository. The runner should appear in the list with self-hosted, gpu, and arm64.
Step 3: Route a job to labeled runners
name: GPU benchmark
on: [push]
jobs:
benchmark:
runs-on: [self-hosted, gpu, arm64]
steps:
- run: ./ci/gpu-benchmark.sh
This job waits until a runner carrying all three labels is idle. If no matching runner exists, the job remains pending and does not run on an unlabeled self-hosted runner.
Step 4: Bound concurrency
Self-hosted runners do not enforce per-runner job limits by default. Too many concurrent jobs can exhaust CPU, memory, or disk I/O. Set a limit at the runner group level in Settings > Actions > Runner groups, or configure the runner application to accept only one job at a time.
Step 5: Verify placement and labels
Run this from a machine with GitHub CLI authenticated as a repository admin:
# List repository runners and inspect labels
gh api /repos/OWNER/REPO/actions/runners --jq '.runners[] | {name, labels: [.labels[].name], busy}'
Expected check: each runner object includes a labels array. Confirm the labels match the values referenced in runs-on. After a workflow run, open the job summary; GitHub shows the runner name and ID at the top of the job log.
Risks and limitations
- Stale labels: If hardware is repurposed, labels no longer describe the machine. Audit labels quarterly against actual capabilities.
- No automatic patching: Unlike GitHub-hosted runners, you own OS updates and container image maintenance on self-hosted hosts.
- Token lifecycle: Registration tokens are short-lived but reusable until expiration. Remove unused runners from the Runners page to reduce the attack surface.
Verification checklist
- Confirm the runner appears on Settings > Actions > Runners with the expected labels.
- Call
GET /repos/{owner}/{repo}/actions/runnersand verify each runner'slabelsarray. - Trigger a workflow using
runs-on: [self-hosted, your-label]and confirm the runner name in the job header. - Check that concurrent jobs on the runner stay below the group concurrency limit.
Label-based routing works best when your team already maintains self-hosted infrastructure and needs deterministic placement without provisioning new runner groups for every workload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.