GitHub-hosted runners are a fine default. You stop needing them the moment a build wants something they don't have — your private package registry, a specific toolchain version you're tired of reinstalling every run, a database on your own network, or just more control over the machine. That's when a self-hosted runner on a VPS earns its keep.
This guide gets one running properly: installed, registered, alive under systemd, and — the part people get wrong — safe. Let's start with that last one, because it's the part that bites.
The one security rule
A GitHub Actions workflow runs arbitrary code — whatever is in the workflow file, and whatever that code pulls in. On your own repo that's your code, and it's fine. On a public repo, a pull request from a stranger can run their code on your runner. That's not a bug; it's how CI works. GitHub's own documentation says plainly: don't use self-hosted runners with public repositories.
So the rule is simple and non-negotiable: self-hosted runners are for private repos. If your repo is public, use GitHub-hosted runners and move on. Everything below assumes a private repo.
What a runner needs from the box
It depends on the build, and you should size to yours rather than a number off a page:
- A typical compile-and-test job — 2 cores and 4-8 GB is comfortable. Small ($8, 4 vCPU / 4 GB) or Medium ($12, 6 vCPU / 6 GB) covers most of these.
- Heavier builds — large native compiles, big Docker image builds, memory-hungry test suites — want more headroom. Watch one real run (
htopwhile it builds) and size to what you actually see, not to hope.
Disk matters too: build caches, Docker layers, and cloned repos add up. Keep an eye on it and prune.
1. Prepare the box
Create a non-root user for the runner — GitHub's installer refuses to run as root anyway, and you want it that way:
sudo adduser --disabled-password --gecos "" runner
sudo usermod -aG sudo runner # only if your builds genuinely need sudo
Install whatever your builds need — a language toolchain, Docker, build tools. For example, if your jobs build containers:
sudo apt update && sudo apt install -y docker.io
sudo usermod -aG docker runner
2. Download and register the runner
In your repo (or org) on GitHub, go to Settings → Actions → Runners → New self-hosted runner. GitHub gives you the exact download commands and a registration token (it's short-lived — grab it fresh). As the runner user:
sudo -iu runner
mkdir actions-runner && cd actions-runner
# use the exact URL GitHub shows you for your OS/arch:
curl -o actions-runner-linux-x64.tar.gz -L "URL_FROM_GITHUB"
tar xzf actions-runner-linux-x64.tar.gz
./config.sh --url https://github.com/YOUR_ORG/YOUR_REPO --token YOUR_TOKEN
config.sh asks for a runner name, labels, and a work folder — the defaults are fine to start. Labels are how your workflow targets this runner (runs-on: self-hosted).
3. Run it as a systemd service
The runner ships with a helper that installs a systemd service for you — use it, so the runner survives reboots and restarts on failure. Still as the runner user's install, but the service commands need root:
sudo ./svc.sh install runner
sudo ./svc.sh start
sudo ./svc.sh status
That registers actions.runner.* as a systemd unit running as the runner user, starting on boot. Logs go to journald:
sudo journalctl -u 'actions.runner.*' -f
Back in GitHub's Runners page, your runner now shows Idle — green dot. Point a workflow at it:
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- run: make test
Push, and the job runs on your box.
4. Keep it clean
A self-hosted runner reuses its filesystem between jobs — that's the speed win (warm caches) and the footgun (leftover state). Two habits keep it healthy:
- Prune regularly. Docker especially —
docker system pruneon a cron, or disk fills silently with dead layers. - Don't store secrets on the box. Use GitHub Actions secrets, injected per-run, not files sitting in the runner's home. If the runner is compromised, anything on disk goes with it.
If you need a truly clean environment per job, run each job inside a container step — the runner stays, the job's mess doesn't.
When to self-host, honestly
Self-host when you need your own toolchain baked in, access to private network resources, or control over the machine. Stay on GitHub-hosted runners when a clean, throwaway environment and their minutes suit you — that's genuinely simpler, and simpler is worth something. And never, on a public repo, self-host. That one isn't a preference.
If a private-repo runner is what you need: pick a plan — Small ($8) for lighter builds, Medium ($12) when they get heavier — pay in USDC or USDT (no KYC, no documents), and you'll have root in about 60 seconds. Then work down this page and you'll have a runner picking up jobs a few minutes later.