GitHub-hosted runners are convenient, but limited in CPU, RAM and free minutes, and start from a clean machine every time. A self-hosted runner on your own VPS gives you more power, persistent caches (Docker layers, dependencies), access to private networks, and no per-minute billing. It also means you are responsible for its security.
Security first
A runner executes whatever code a workflow tells it to. Rules:
- Use self-hosted runners only for private repositories (or trusted organisations). On a public repository, anyone could open a pull request that runs code on your server.
- Run the runner as an unprivileged user on a dedicated VPS, not on your production server.
- Prefer ephemeral runners (one job, then reset) for stronger isolation.
- Don’t store long-lived secrets on the runner host. Pass them through GitHub secrets. See GitHub Actions secrets.
Requirements
- Ubuntu or Debian VPS. 2–4 vCPU and 4–8 GB RAM suit most builds. More for heavy compilation or many parallel jobs. See vCPU explained.
- Outbound HTTPS to GitHub. No inbound ports are needed: the runner polls GitHub for jobs.
Install the runner
In GitHub: repository (or organisation) → Settings → Actions → Runners → New self-hosted runner. It shows download and configuration commands with a one-time token, similar to:
sudo useradd -m -s /bin/bash runner
sudo -iu runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner.tar.gz -L https://github.com/actions/runner/releases/download/v<version>/actions-runner-linux-x64-<version>.tar.gz
tar xzf actions-runner.tar.gz
./config.sh --url https://github.com/your-org/your-repo --token <TOKEN> --labels vps,docker --name runner-1 --unattended
exit
Install it as a systemd service (from the runner directory, as root):
cd /home/runner/actions-runner
sudo ./svc.sh install runner
sudo ./svc.sh start
sudo ./svc.sh status
The runner now shows as Idle in GitHub’s settings. See systemctl commands.
Use it in workflows
jobs:
build:
runs-on: [self-hosted, linux, vps]
steps:
- uses: actions/checkout@v4
- run: docker build -t myapp:${{ github.sha }} .
Labels route jobs to the right runners (for example docker, arm64, gpu).
Docker access
For Docker builds, add the runner user to the docker group:
sudo usermod -aG docker runner
sudo ./svc.sh stop && sudo ./svc.sh start
The docker group is root-equivalent on that host, another reason to keep the runner on a dedicated VPS. Rootless Docker reduces the risk. See Docker rootless mode.
Persistent Docker layer caches are a big speed win compared with hosted runners. See GitHub Actions Docker builds.
Keep it clean
Self-hosted runners don’t reset between jobs (unless ephemeral):
- workspace files persist in
_work/. Useactions/checkoutwithclean: true(the default); - Docker images and build cache accumulate. Schedule cleanup:
# /etc/cron.d/runner-cleanup
0 4 * * * runner docker system prune -af --filter "until=72h" > /dev/null 2>&1
See Docker disk cleanup.
Ephemeral runners
./config.sh --ephemeral … registers a runner that takes one job, then unregisters. Combine it with a script, or containers, that recreate a fresh runner after each job. Tools such as Actions Runner Controller (Kubernetes) automate this at scale.
Updates and monitoring
- The runner auto-updates itself by default. Keep the OS updated too. See automatic security updates.
- Watch disk space and job queues. Offline runners show in the GitHub UI.
- Logs:
journalctl -u actions.runner.*and_diag/in the runner directory.
Quick reference
| Task | Command |
|---|---|
| Configure | ./config.sh --url … --token … --labels … |
| Install service | sudo ./svc.sh install runner && sudo ./svc.sh start |
| Use in workflow | runs-on: [self-hosted, linux] |
| Docker builds | add the runner user to docker (dedicated host only) |
| One job per runner | --ephemeral |
| Remove | ./config.sh remove --token <token> |
Related: GitLab Runner and VPS for developers.