DevOpsGitHub

GitHub Actions: Self-Hosted Runners

TT
TopicTrick Team
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.