DevOpsGitHub

GitHub Actions: Self-Hosted Runners

Master the hardware. Learn when to use your own servers as GitHub Runners for massive speed, zero cost, and access to private local resources.

TT
Emily Ross
••9 min read
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

FactorGitHub-HostedSelf-Hosted
Cost at scalePer-minute billingFixed infrastructure cost
Hardware2 vCPU, 7GB RAMWhatever you provision
GPU accessNoYes
Private network accessNoYes (runner lives inside)
MaintenanceNoneYour responsibility
Setup timeZero10-30 minutes
Security isolationStrong (fresh VM per job)Requires careful configuration
OS optionsUbuntu, Windows, macOSAny 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

bash
# 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 start

Organisation-Level Runner

An organisation-level runner can serve all repositories in the organisation:

bash
./config.sh \
  --url https://github.com/my-org \
  --token <ORG_REGISTRATION_TOKEN> \
  --name shared-runner-01 \
  --labels linux,x64,shared \
  --unattended

Get 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:

yaml
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:integration

Adding and Removing Labels

bash
# 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 -> Edit

Runner Groups

Runner groups let you restrict which repositories can use which runners. Only available on GitHub Team and Enterprise plans.

text
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:

yaml
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.

bash
# 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.sh

Ephemeral Runner Automation with Docker

dockerfile
# 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"]
bash
#!/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.sh

Autoscaling with Kubernetes: Actions Runner Controller

For production workloads, the Actions Runner Controller (ARC) runs ephemeral runners as Kubernetes pods that scale automatically:

bash
# 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
yaml
# 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

yaml
# .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:

yaml
# 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 log

Restrict self-hosted runners to private repositories only, or use runner groups with explicit repository access control.

Run Runners as a Non-Privileged User

bash
# 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 install

Isolate Runners with Docker

Run each job inside a Docker container to prevent the job from accessing the host filesystem:

yaml
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 build

Rotate Registration Tokens

Registration tokens expire after one hour. Store runner credentials securely:

bash
# 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-token

Monitoring Runner Health

bash
# 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/runners

Common 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:

yaml
steps:
  - name: Clean runner workspace
    run: |
      rm -rf "$GITHUB_WORKSPACE"/*
      df -h   # Verify disk space after cleanup
  - uses: actions/checkout@v4

For 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.