Hosted CI minutes are metered; a small VPS is not. The build cache stays warm, the Docker layers stay pulled, and the machine costs the same whether you push once a week or forty times a day. There is exactly one rule you must not break.
The rule: never on a public repository
A self-hosted runner executes whatever the workflow file says. On a public repository, anyone can open a pull request from a fork — and if your workflow runs on pull_request, their code runs on your machine. GitHub says this in its own documentation and it is not a theoretical risk: it is how self-hosted runners get turned into crypto miners and credential dumps.
Private repositories only, unless you are running ephemeral runners inside disposable infrastructure and you know exactly why that changes the answer. If you need CI on a public repo, use the hosted runners. That is what they are good at.
What to size it at
CI is bursty CPU and steady disk. 2 vCPU and 4 GB handles typical Node, Python and Go builds without drama; Docker image builds and anything that compiles native code will use everything you give it.
Disk is the sleeper. Between the workspace, the Docker layer cache, and the package caches, a busy runner will quietly eat tens of gigabytes. Plan to prune (below), and know how you would grow the disk before you need to — add extra storage to your VPS covers that on our machines.
One pleasant surprise: a runner only makes outbound connections. It polls the CI service for work; nothing connects in. That means NAT IPv4 is completely fine here — no dedicated address, no port forwarding, nothing to configure. It is one of the few server workloads with no networking caveat at all (NAT IPv4 vs a dedicated IP if you want the background).
Step 1: the machine and a non-root user
Deploy an Ubuntu LTS image and do the basics first: connect to your VPS over SSH and secure your VPS. Then make a user for the runner that is not root and not you:
sudo adduser --disabled-password --gecos "" runner
If your jobs need Docker, adding runner to the docker group grants effectively root on the host, because it can mount the host filesystem into a container. That is an acceptable trade on a single-purpose machine that only builds your own private code. It is not acceptable on a machine that does anything else.
Step 2: GitHub Actions runner
Get the download command and registration token from Settings → Actions → Runners → New self-hosted runner on the repository or organisation — the token is short-lived, so generate it when you are ready to use it.
sudo -u runner -i
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.322.0/actions-runner-linux-x64-2.322.0.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
./config.sh --url https://github.com/YOURORG/YOURREPO \
--token YOUR_REGISTRATION_TOKEN \
--labels self-hosted,linux,x64,vps \
--unattended
Install it as a service so it survives a reboot. This part runs as root because it writes a systemd unit, but the service itself runs as runner:
exit
cd /home/runner/actions-runner
sudo ./svc.sh install runner
sudo ./svc.sh start
Target it from a workflow with the labels you set:
jobs:
build:
runs-on: [self-hosted, linux, vps]
Step 3: make it ephemeral if you can
A long-lived runner accumulates state: leftover files, mutated global config, a node_modules from three branches ago. Worse, one job can leave something behind that the next job picks up.
./config.sh --ephemeral makes the runner take exactly one job and then deregister. Pair it with a supervisor that re-registers a fresh one and you get the isolation of hosted CI with the warmth of your own cache. It is more moving parts; it is also the difference between "the build only fails on the runner" and a machine you trust.
Step 4: GitLab, if that is where you live
Same shape, different words. Install gitlab-runner, then:
sudo gitlab-runner register \
--non-interactive \
--url https://gitlab.com/ \
--token YOUR_RUNNER_TOKEN \
--executor docker \
--docker-image alpine:latest
The docker executor is the one you want: each job gets a fresh container, so the isolation problem from step 3 is solved by default. Its cost is that Docker layer caching between jobs needs deliberate configuration rather than happening for free.
Step 5: stop it filling the disk
This is the maintenance that actually matters. A weekly timer is enough:
docker system prune -af --filter "until=168h"
docker volume prune -f
Add a cleanup of the runner's _work directory if your jobs leave large artefacts behind, and put a disk-usage alert somewhere you will see it. A CI runner that is 100% full does not fail loudly — it fails weirdly, halfway through a step, with an error about something unrelated.
Step 6: secrets
The runner holds real credentials in memory while a job runs. Two habits worth having from day one: keep the machine single-purpose, so nothing else on it can read process memory or the workspace, and prefer short-lived tokens (OIDC to your cloud provider) over long-lived keys stored in repository secrets. If a token can only be exchanged for fifteen minutes of access, a compromised job is an incident rather than a catastrophe.
On overnight.host
Full disclosure: this is what we sell. A runner is a steady, boring workload — two cores and a few gigabytes, up all month, which is exactly what a monthly VPS is priced for.
Linux KVM VPS — EUR 4.99 to EUR 59.99 a month, on our own single-tenant bare metal in Dallas, TX and Charlotte, NC. Full hardware virtualisation (KVM), your own kernel, full root. Six tiers, vps-starter to vps-ultra. Starter is 1 vCPU, 1 GiB RAM, 25 GB disk.
You order in the shop, pay by card (Stripe) or SEPA bank transfer, and your login details are e-mailed to you once the service is set up. Support is e-mail, run by one person, with no guaranteed response time. All prices are final totals under the German small-business rule (§19 UStG); no VAT is added or shown.
Order vps-starter → · Linux KVM VPS overview
FAQ
Is a self-hosted runner cheaper than hosted minutes?
It stops being metered, which is the point. A machine that costs the same every month is easier to reason about than a minute counter, and the warm cache often makes builds faster on modest hardware than a cold hosted runner on fast hardware. Whether it is cheaper depends entirely on how much you build.
Can I use a self-hosted runner on a public repository?
You should not. A pull request from a fork can run arbitrary code on your machine. Keep self-hosted runners on private repositories.
How much disk does a runner need?
More than you think — the workspace plus the Docker layer cache plus package caches. Start at 40 GB or more for anything that builds containers, and prune on a schedule.
Does a runner need a dedicated IP address?
No. It only makes outbound connections, so NAT IPv4 with no forwarded ports works perfectly.
Should I run one runner or several?
One per concurrent job you want. Two small runners on separate machines beat one large runner for throughput, because CI parallelism is mostly about not queueing.
Written by the person who runs overnight.host: a small, honest hosting company on dedicated bare metal — Linux VPS, game servers, web hosting. Live status at up.overnight.host.
Originally published at overnight.host — the canonical, kept-current version of this guide.
Top comments (0)