Set Up GitHub Actions Self-hosted Runner on VPS
GitHub Actions self-hosted runner is an agent you install on your own VPS to run workflows in place of GitHub-hosted runners — with no build minute limits, freedom to choose OS and software, and direct access to your internal network and deployment servers without routing through cloud intermediaries. Self-hosted runners are ideal for projects requiring long builds, testing against real databases, or deploying to servers on private networks. This guide teaches you how to set up a secure runner, run Docker, and configure systemd for automatic startup.
Benefits of Self-hosted Runner
- Unlimited minutes: GitHub's free plan offers only 2,000 minutes per month — self-hosted runners have no limits
- Full environment: Install any tools, databases, or Docker images you need
- Fast deployment: The runner lives on the same server as production — copy files directly without external transfers
- Network isolation: Access services in private networks that GitHub-hosted runners cannot reach
- Secrets stay local: Credentials do not need to transit GitHub's infrastructure
Create a Separate User for Runner (Security)
# Never run the runner as root — create a dedicated user
useradd -m -s /bin/bash github-runner
usermod -aG docker github-runner # if you need Docker access
# Create the directory for the runner
mkdir -p /opt/github-runner
chown github-runner:github-runner /opt/github-runner
Download and Configure Runner
su - github-runner
cd /opt/github-runner
# Download the runner (check the latest version at github.com/actions/runner/releases)
RUNNER_VERSION="2.316.1"
curl -sL "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz" -o runner.tar.gz
tar xzf runner.tar.gz
rm runner.tar.gz
# Configure — requires a registration token from GitHub
# Go to repo → Settings → Actions → Runners → New self-hosted runner
# Copy the token and paste it below
./config.sh \
--url https://github.com/YOUR_ORG/YOUR_REPO \
--token YOUR_REGISTRATION_TOKEN \
--name "vps-runner-01" \
--labels "linux,x64,vps,production" \
--work "_work" \
--unattended
Set Up systemd Service for Auto-start
# Exit the github-runner user and return to root
exit
# Install the service (script is included with runner)
cd /opt/github-runner
./svc.sh install github-runner
./svc.sh start
# Check the status
./svc.sh status
systemctl status actions.runner.YOUR_ORG-YOUR_REPO.vps-runner-01.service
Example Workflow Using Self-hosted Runner
# .github/workflows/deploy.yml
name: Deploy to VPS
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: [self-hosted, linux, vps] # Select runner by labels
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: |
docker build -t myapp:${{ github.sha }} .
docker tag myapp:${{ github.sha }} myapp:latest
- name: Deploy
run: |
docker-compose pull
docker-compose up -d --no-build
docker system prune -f
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Health check
run: curl -f http://localhost:3000/health || exit 1
Set Up Runner for Organization (Use Across Multiple Repos)
# Instead of binding the runner to a single repo, bind it to the organization
# Go to org → Settings → Actions → Runners → New self-hosted runner
# Use the organization URL instead of the repo URL
./config.sh \
--url https://github.com/YOUR_ORG \
--token ORG_REGISTRATION_TOKEN \
--name "vps-shared-runner" \
--labels "linux,x64,shared"
# Restrict runner to specific repos only
# org → Settings → Actions → Runner groups
Verify the Runner is Working Correctly
# Check the systemd service status
systemctl status actions.runner.*.service
# View logs in real-time
journalctl -u actions.runner.*.service -f
# Verify the runner is online on GitHub
# Go to repo → Settings → Actions → Runners
# You should see the status "Idle" or "Active"
# Test by triggering a workflow manually
# repo → Actions → Select your workflow → Run workflow
# Check the work directory after a successful run
ls /opt/github-runner/_work/
/opt/github-runner/_diag/ — if a job fails, check the most recent files in this directory first. You'll see every step that ran and the full error message.Common Issues & Troubleshooting
Error: "Runner is already configured"
This occurs when you configure without removing the old runner first.
# Remove the old configuration first
cd /opt/github-runner
./config.sh remove --token YOUR_REMOVAL_TOKEN
# Or force remove (if the token has expired)
rm -f .runner .credentials .credentials_rsaparams
Error: "Docker: permission denied" in workflow
The github-runner user is not in the docker group.
usermod -aG docker github-runner
# You must log out/log back in or restart the service
systemctl restart actions.runner.*.service
# Test the fix
su - github-runner -c "docker ps"
Runner shows "Offline" on GitHub even though the service is running
Usually caused by an expired token or a firewall blocking outbound HTTPS.
# Check connectivity to GitHub
curl -I https://api.github.com
curl -I https://github.com
# Check your firewall
ufw status
# Allow outbound 443 if it's blocked
ufw allow out 443/tcp
Error: "No space left on device" during build
Docker images accumulate until the disk fills. Set up automatic cleanup.
# Manual cleanup
docker system prune -af --volumes
# Set up a cron job to clean up every night
crontab -e -u github-runner
# Add:
# 0 2 * * * docker system prune -f --filter "until=24h"
Frequently Asked Questions
Is a self-hosted runner secure?
Yes, if configured correctly — run it as a non-root user with its own account, use it only with private repositories, and configure firewall rules so the runner accesses only the resources it needs. Never use self-hosted runners with public repositories without additional protection, as pull requests from outside contributors can execute code on your server.
Can a single runner execute multiple jobs simultaneously?
A single runner executes one job at a time. For parallel execution, install multiple runner instances on the same server (with different names) or configure the max-parallel setting in your workflow to control concurrency.
How secure are secrets in GitHub Actions?
Secrets are encrypted and delivered to the runner as environment variables during the job — they are not logged and are automatically masked in output. However, code running in your workflow on the runner can still access secrets via ${{ secrets.NAME }}. Always review all pull request workflows before merging to ensure they are not exfiltrating secrets.