Why own the runner
Hosted CI minutes are fine until your pipeline spends four minutes restoring caches and thirty seconds compiling. A dedicated runner flips that ratio. The Docker layer cache, the language package store and the compiler cache all live on the same NVMe volume between jobs, so incremental builds behave like incremental builds.
The cost model is also flat. A FastNode VPS is billed per month, not per minute, so a busy release week costs the same as a quiet one. You get full root on a dedicated virtual machine, which means nested containers, custom kernel modules inside the guest, and any toolchain you like without asking a vendor to add it to their image.
Pick the right size
Runner sizing is about concurrency more than raw speed. Work out how many jobs you want in flight at once, then multiply by the footprint of your heaviest job.
| Workload | Suggested plan | Concurrent jobs |
|---|---|---|
| Lint, unit tests, small Go or Rust crates | Boost (2 vCPU, 4 GB) | 1-2 |
| Docker image builds for a few services | Turbo (4 vCPU, 8 GB) | 2 |
| Node or Java monorepo, integration tests with a database | Nitro (4 vCPU, 12 GB) | 2-3 |
| Matrix builds, heavy linking, Android | Warp or Hyper | 3-6 |
RAM is the usual bottleneck. A webpack build or a Gradle daemon will happily eat 3 GB each. If jobs get killed by the OOM killer, add memory before you add cores.
GitHub Actions: register a runner
Create a dedicated user, then fetch the runner package from the repository or organisation settings page (Settings, Actions, Runners, New self-hosted runner). The token shown there is short-lived.
sudo useradd -m -s /bin/bash runner
sudo usermod -aG docker runner
sudo -iu runner
mkdir actions-runner && cd actions-runner
# download and extract the tarball shown on the settings page, then:
./config.sh --url https://github.com/your-org --token XXXXXXXX \
--name lon-ci-01 --labels london,nvme,docker --unattended
exit
cd /home/runner/actions-runner
sudo ./svc.sh install runner
sudo ./svc.sh start
Target it in a workflow with runs-on: [self-hosted, london]. Labels are how you keep heavy jobs on the big box and quick checks elsewhere.
GitLab: Docker executor
The Docker executor gives each job a clean container while the host keeps pulled images. On Debian or Ubuntu:
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt install gitlab-runner
sudo gitlab-runner register \
--url https://gitlab.example.com \
--token glrt-XXXXXXXX \
--executor docker \
--docker-image debian:12 \
--description lon-ci-01
Then tune /etc/gitlab-runner/config.toml: set concurrent at the top to your job budget, and add a persistent cache volume so dependency directories survive between jobs.
concurrent = 2
[[runners]]
[runners.docker]
volumes = ["/cache", "/srv/ci-cache/npm:/root/.npm"]
pull_policy = ["if-not-present"]
Ephemeral or persistent
A persistent runner is quick because it remembers things. That is also its weakness: one job can leave files, credentials or background processes behind for the next. Use persistent runners for code you trust, from repositories where only your team can push.
Ephemeral runners accept one job and then deregister. GitHub supports this with ./config.sh --ephemeral; pair it with a systemd unit that re-registers on exit, or wipe the work directory between jobs. You lose some cache warmth but keep the Docker layer cache on the host, which is where most of the time goes anyway.
Lock it down
- Never expose a self-hosted runner to fork pull requests on a public repository. Anyone can open a PR that runs arbitrary code on your machine.
- Runners connect outbound only. Use the portal firewall to deny all inbound traffic except SSH from your own addresses.
- Store deploy secrets in the CI platform's secret store, scoped to protected branches, not in files on the runner.
- Membership of the
dockergroup is effectively root. Treat the runner user accordingly and keep nothing else sensitive on the box. - Take a snapshot after the runner is configured. If a job trashes the host, restore and re-register in minutes.
Keep the disk healthy
Caches grow until they don't fit. Add a weekly prune via a systemd timer or cron:
docker system prune -af --filter "until=168h"
docker builder prune -af --keep-storage 20GB
Check the CPU and bandwidth graphs in the portal after a busy day. If the CPU line sits at the ceiling during working hours, it is time to move up a plan; ask support and they will move you.
Because the box sits in London, artefact pushes to UK-hosted registries and deploys to UK servers stay short-haul. Start with Turbo, run a week of real builds, and resize from data rather than guesses.