Self-Hosted GitHub Actions Runner on a VPS: Faster CI, Private Deployments, and No Minute Limits
GitHub Actions provides 2,000 free minutes/month on GitHub-hosted runners for free accounts (500 MB storage). Self-hosted runners eliminate minute limits, provide faster builds (no queue wait, dedicated hardware), enable access to private network resources the VPS can reach, and allow running Docker-in-Docker, GPU workloads, or custom software that GitHub’s hosted runners don’t support.
GitHub-Hosted vs Self-Hosted Runners
- GitHub-hosted: Managed, fresh VM per job, 2 vCPU / 7 GB RAM, limited minutes on free plans, can’t access private networks
- Self-hosted: Your VPS hardware, no minute limits, persistent tool cache between runs, access to your private services and databases, Docker cache persists
- Best use cases for self-hosted: Docker image builds (cache speeds up significantly), private deployments (runner can SSH to production), GPU inference testing, database integration tests against real databases
Step 1: Create a Dedicated Runner User
<code"># Create isolated user for the runner sudo useradd -m -s /bin/bash github-runner sudo usermod -aG docker github-runner # Allow Docker usage # Switch to runner user sudo su - github-runner mkdir -p /home/github-runner/actions-runner cd /home/github-runner/actions-runner
Step 2: Download and Register the Runner
<code"># Download latest runner (check https://github.com/actions/runner/releases for latest version)
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.320.0/actions-runner-linux-x64-2.320.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
# Register with your repository:
# GitHub → Repository → Settings → Actions → Runners → New self-hosted runner
# Copy the token from that page, then:
./config.sh \
--url https://github.com/YOUR_ORG/YOUR_REPO \
--token YOUR_REGISTRATION_TOKEN \
--name "vps-runner-01" \
--labels "self-hosted,linux,x64,docker" \
--work "_work" \
--unattended
# For organization-wide runner (accessible to all repos):
# GitHub → Organization → Settings → Actions → Runners → New runner
./config.sh \
--url https://github.com/YOUR_ORG \
--token YOUR_ORG_TOKEN \
--name "vps-runner-01" \
--labels "self-hosted,linux,x64,vps"
Step 3: Run as systemd Service
<code"># Install systemd service (as root) exit # Return to root/sudo user sudo /home/github-runner/actions-runner/svc.sh install github-runner sudo systemctl enable actions.runner.YOUR_ORG-YOUR_REPO.vps-runner-01 sudo systemctl start actions.runner.YOUR_ORG-YOUR_REPO.vps-runner-01 # Verify runner is online sudo systemctl status actions.runner.* # Also check: GitHub → Settings → Actions → Runners → should show "Idle"
Step 4: Use the Self-Hosted Runner in Workflows
<code"># .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: self-hosted # Use your VPS runner
# Or use specific label: runs-on: [self-hosted, docker]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t myapp:${{ github.sha }} .
docker tag myapp:${{ github.sha }} myapp:latest
- name: Run tests
run: |
docker run --rm myapp:latest npm test
- name: Deploy to production
run: |
# Runner can SSH to services on the same VPS network
# or deploy Docker containers directly
docker compose -f /opt/myapp/docker-compose.yml pull
docker compose -f /opt/myapp/docker-compose.yml up -d
echo "Deployed myapp:${{ github.sha }}"
Step 5: Docker Build Cache (Key Advantage)
<code"># Self-hosted runners keep Docker layer cache between builds
# GitHub-hosted runners start fresh each time
# First build: 5 minutes (downloads all base images and dependencies)
# Subsequent builds: 30–60 seconds (only changed layers rebuilt)
# .github/workflows/build.yml
- name: Build with cache
run: |
docker build \
--cache-from myapp:latest \ # Use previous image as cache
--tag myapp:${{ github.sha }} \
--tag myapp:latest \
.
Step 6: Access Private VPS Services
<code"># The runner runs on your VPS — it can access localhost services:
- name: Run integration tests
run: |
# Connect to your real PostgreSQL (not a service container)
export DATABASE_URL=postgresql://testuser:password@localhost:5432/testdb
npm run test:integration
- name: Warm application cache
run: |
# Clear Redis cache on the same VPS
redis-cli -h localhost FLUSHDB
- name: Deploy and verify
run: |
docker compose up -d myapp
sleep 5
# Health check against localhost
curl -f http://localhost:3000/health
Step 7: Security Best Practices
<code"># IMPORTANT: Never use self-hosted runners for PUBLIC repositories # Malicious PRs can run arbitrary code on your VPS # Restrict to private repos only (GitHub setting): # Settings → Actions → Runners → select runner → can only be used by private repos # Limit runner permissions: sudo visudo -f /etc/sudoers.d/github-runner
<code">github-runner ALL=(ALL) NOPASSWD: /usr/bin/docker, /usr/bin/docker-compose # Do NOT give ALL sudo access
<code"># Isolate builds with Docker:
- name: Run in container
run: |
docker run --rm \
--network none \ # No network access for build step
--memory 512m \ # Limit memory
--cpus 1 \ # Limit CPU
--user nobody \ # Non-root inside container
node:20-alpine \
sh -c "npm ci && npm test"
Runner Groups (Organization-Level)
<code"># At the organization level, create runner groups:
# Organization → Settings → Actions → Runner groups → New group
# Assign runners to groups:
# Each group can be restricted to specific repositories
# Assign workflows to runner groups:
jobs:
deploy:
runs-on:
group: production-runners # Only use runners in this group
labels: [linux, docker]
Getting Started
A self-hosted runner on a 2 vCPU / 4 GB RAM Ubuntu VPS at VPS.DO handles most CI workloads faster than GitHub-hosted runners — no queue wait, Docker cache persists, and builds access VPS-local services. For teams hitting GitHub’s 2,000 free minutes or needing Docker build caches, a dedicated CI VPS with a self-hosted runner typically reduces build times by 50–70%.
Conclusion
Self-hosted GitHub Actions runners eliminate minute limits, provide persistent Docker build caches that dramatically speed up image builds, enable access to private network services, and support custom hardware requirements. The runner runs as a systemd service, automatically picks up jobs when idle, and reports status to GitHub’s UI. For teams with private repositories and regular Docker-based CI workflows, a self-hosted runner on a VPS is a cost-effective and faster alternative to GitHub-hosted runners.