Set Up GitHub Actions Self-hosted Runner on VPS for CI/CD

Set Up GitHub Actions Self-hosted Runner on VPS

June 2026 · 8 min read

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

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
Tip: Do not use self-hosted runners with public repositories without protection — anyone who forks your repo can run workflows and access your server. Always enable "Require approval for first-time contributors" in repository settings before enabling self-hosted runners on public repos.

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/
Tip: Runner logs are stored in /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.