GitHub Actions: Self-Hosted Runners

GitHub Actions: Self-Hosted Runners
GitHub-hosted runners are convenient, but they come with limits: 2 vCPUs, 7GB RAM, no access to your private network, and per-minute billing. Self-hosted runners let you run GitHub Actions workflows on machines you control — your data centre, a cloud VM, or a Kubernetes cluster — with full access to internal resources and no per-minute cost beyond your own infrastructure.
This guide covers when self-hosted runners are the right choice, registering and configuring runners, labelling and grouping runners for workflow targeting, ephemeral runner patterns, autoscaling with Kubernetes, and the security model for keeping self-hosted runners safe.
GitHub-Hosted vs Self-Hosted: When to Choose Each
| Factor | GitHub-Hosted | Self-Hosted |
|---|---|---|
| Cost at scale | Per-minute billing | Fixed infrastructure cost |
| Hardware | 2 vCPU, 7GB RAM | Whatever you provision |
| GPU access | No | Yes |
| Private network access | No | Yes (runner lives inside) |
| Maintenance | None | Your responsibility |
| Setup time | Zero | 10-30 minutes |
| Security isolation | Strong (fresh VM per job) | Requires careful configuration |
| OS options | Ubuntu, Windows, macOS | Any OS |
Use GitHub-hosted runners for open-source projects, small teams, and workflows that do not require private network access or specialised hardware.
Use self-hosted runners when you need GPU access for ML tests, must reach internal databases or APIs, have high build volumes that make per-minute billing expensive, or require specific hardware configurations (ARM, large memory).
Registering a Self-Hosted Runner
Repository-Level Runner
# 1. Create a directory for the runner
mkdir actions-runner && cd actions-runner
# 2. Download the latest runner package (check GitHub for current version)
curl -o actions-runner-linux-x64-2.316.1.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.316.1/actions-runner-linux-x64-2.316.1.tar.gz
# 3. Extract
tar xzf ./actions-runner-linux-x64-2.316.1.tar.gz
# 4. Configure — get the token from Repo → Settings → Actions → Runners → New self-hosted runner
./config.sh \
--url https://github.com/my-org/my-repo \
--token <REGISTRATION_TOKEN> \
--name prod-runner-01 \
--labels linux,x64,production \
--unattended
# 5. Install as a systemd service so it starts automatically on boot
sudo ./svc.sh install
sudo ./svc.sh startOrganisation-Level Runner
An organisation-level runner can serve all repositories in the organisation:
./config.sh \
--url https://github.com/my-org \
--token <ORG_REGISTRATION_TOKEN> \
--name shared-runner-01 \
--labels linux,x64,shared \
--unattendedGet the organisation token from: Org → Settings → Actions → Runners → New runner.
Targeting Self-Hosted Runners with Labels
Labels let you route specific workflows to specific runners. When you register a runner, you assign labels. In your workflow, you reference those labels in runs-on:
jobs:
# Routes to any runner with the 'linux' and 'x64' labels
build:
runs-on: [self-hosted, linux, x64]
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
# Routes to a GPU runner for ML tests
ml-tests:
runs-on: [self-hosted, linux, gpu, a100]
steps:
- uses: actions/checkout@v4
- run: python -m pytest tests/ml/ --gpu
# Routes to a runner inside your private network
integration-tests:
runs-on: [self-hosted, linux, private-network]
steps:
- uses: actions/checkout@v4
- name: Test against internal database
env:
DATABASE_URL: postgresql://internal-db.company.local:5432/testdb
run: npm run test:integrationAdding and Removing Labels
# Add a label to a running runner
./config.sh --labels new-label --replace-labels
# Or manage labels via the GitHub UI:
# Repo → Settings → Actions → Runners → click runner → EditRunner Groups
Runner groups let you restrict which repositories can use which runners. Only available on GitHub Team and Enterprise plans.
Org → Settings → Actions → Runner groups → New runner group
Name: production-runners
Repository access: Selected repositories
Select: my-org/api-service, my-org/worker-service
(Not accessible to: forks or other repos)In workflows, you target a runner group by combining labels:
runs-on:
group: production-runners
labels: [linux, x64]Ephemeral Runners: The Secure Pattern
Non-ephemeral (persistent) runners accumulate state: leftover build artefacts, cached credentials, modified environment variables. A job that runs on a contaminated runner can pick up files from a previous job — including from a different repository if the runner is shared.
Ephemeral runners solve this: the runner registers, processes exactly one job, then deletes itself. Each job gets a clean machine.
# Register an ephemeral runner (--ephemeral flag)
./config.sh \
--url https://github.com/my-org/my-repo \
--token <REGISTRATION_TOKEN> \
--ephemeral \
--unattended
# The runner will automatically deregister after completing one job
./run.shEphemeral Runner Automation with Docker
# runner.Dockerfile
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
curl tar libicu-dev libssl-dev \
&& rm -rf /var/lib/apt/lists/*
ARG RUNNER_VERSION=2.316.1
RUN curl -o /tmp/runner.tar.gz -L \
https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
&& mkdir /runner && tar xzf /tmp/runner.tar.gz -C /runner
WORKDIR /runner
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]#!/bin/bash
# entrypoint.sh — registers runner, runs one job, deregisters
./config.sh \
--url "$GITHUB_REPOSITORY_URL" \
--token "$RUNNER_TOKEN" \
--ephemeral \
--unattended \
--name "ephemeral-$(hostname)"
./run.shAutoscaling with Kubernetes: Actions Runner Controller
For production workloads, the Actions Runner Controller (ARC) runs ephemeral runners as Kubernetes pods that scale automatically:
# Install ARC via Helm
helm install arc \
--namespace arc-systems \
--create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
# Create a runner scale set
helm install arc-runner-set \
--namespace arc-runners \
--create-namespace \
--set githubConfigUrl="https://github.com/my-org/my-repo" \
--set githubConfigSecret.github_token="$GITHUB_PAT" \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set# runner-scale-set-values.yaml
githubConfigUrl: "https://github.com/my-org"
githubConfigSecret: arc-github-secret
minRunners: 0 # Scale to zero when idle
maxRunners: 20 # Maximum concurrent runners
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"With ARC, the cluster starts a new pod for each queued job and terminates it when the job completes. You pay for compute only when builds are running.
Workflow Example: Private Network Integration Tests
# .github/workflows/integration-test.yml
name: Integration Tests
on:
push:
branches: [main]
pull_request:
jobs:
integration:
runs-on: [self-hosted, linux, private-network]
environment: staging # Requires approval before running in staging
services:
# Self-hosted runners support service containers
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run integration tests
env:
# This URL is only reachable from inside the private network
DATABASE_URL: ${{ secrets.INTERNAL_DB_URL }}
REDIS_URL: redis://localhost:6379
run: npm run test:integration
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: test-results/Security Best Practices
Never Use Self-Hosted Runners with Public Repositories
A malicious pull request from a fork can run arbitrary code on your runner:
# An attacker's PR could contain:
- run: cat ~/.ssh/id_rsa && env && ls /etc/
# This would print your private keys and environment variables to the logRestrict self-hosted runners to private repositories only, or use runner groups with explicit repository access control.
Run Runners as a Non-Privileged User
# Create a dedicated user for the runner
sudo useradd -m -s /bin/bash github-runner
sudo -u github-runner ./config.sh ...
sudo -u github-runner ./svc.sh installIsolate Runners with Docker
Run each job inside a Docker container to prevent the job from accessing the host filesystem:
jobs:
build:
runs-on: [self-hosted, linux]
container:
image: node:20-alpine # Job runs inside this container, not on the host
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run buildRotate Registration Tokens
Registration tokens expire after one hour. Store runner credentials securely:
# Rotate the runner token via GitHub API
curl -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/my-org/my-repo/actions/runners/registration-tokenMonitoring Runner Health
# Check runner service status
sudo ./svc.sh status
# View runner logs
sudo journalctl -u actions.runner.my-org-my-repo.prod-runner-01.service -f
# List all runners for a repo via GitHub API
curl -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/my-org/my-repo/actions/runnersCommon failure causes:
- Token expired: re-register with a fresh token
- Network connectivity to
github.com: check firewall rules for outbound HTTPS - Disk full: runners accumulate tool cache in
_work/— add a cleanup cron job - Runner version outdated: GitHub periodically deprecates old runner versions
Frequently Asked Questions
Q: Can I use a self-hosted runner on my development laptop?
Yes. The runner agent runs on Windows, macOS, and Linux. Running it on your laptop is useful for testing runner configurations without provisioning a server. However, disable it when not actively testing — an always-on laptop runner is a security risk if anyone pushes to the repository.
Q: How do I clean up the runner's work directory between jobs?
For non-ephemeral runners, add a cleanup step at the start of each job:
steps:
- name: Clean runner workspace
run: |
rm -rf "$GITHUB_WORKSPACE"/*
df -h # Verify disk space after cleanup
- uses: actions/checkout@v4For ephemeral runners, cleanup is automatic — the runner is destroyed after each job.
Q: What happens if a runner goes offline mid-job?
GitHub marks the job as failed after a timeout (default: 6 hours). The workflow will not automatically re-queue the job on a different runner. Design critical workflows with idempotent steps so they can be safely re-run manually.
Q: Can one runner handle multiple jobs simultaneously?
By default, each runner handles one job at a time. To run multiple concurrent jobs, register multiple runners on the same machine with different names. Be careful: concurrent jobs on the same machine can interfere if they write to shared paths.
Q: How do I update the runner software?
GitHub sends an automatic update before the old runner version is deprecated. The runner auto-updates itself. For Kubernetes-based runners via ARC, update the Helm chart to pull a newer runner image.
Key Takeaway
Self-hosted runners give you full control over your CI/CD hardware: access to private networks, GPU support, faster builds on beefy machines, and zero per-minute billing. The operational trade-off is maintenance responsibility and a stricter security model. Use ephemeral runners (via the --ephemeral flag or Kubernetes ARC) for every production workload — they eliminate state contamination between jobs and make each run reproducible. Restrict self-hosted runners to private repositories to prevent malicious PRs from executing code on your infrastructure.
Read next: GitHub Advanced Security: Protection at Scale →
Part of the GitHub Mastery Course — engineering the power.
