Run a self-hosted CI runner on a VPS without handing it your infrastructure
Install a GitHub Actions runner under its own account, give every job a clean workspace, keep secrets and the Docker socket out of reach, and stop builds filling the disk.

A self-hosted runner is a machine that executes whatever code arrives in a workflow file. That is the entire feature, and it is also the entire risk. Treat the runner as hostile-adjacent infrastructure: it will run code you did not write, and the interesting question is what that code can reach.
The default installation, run as your normal user on a box you also use for other things, gives a build script your SSH keys, your cloud credentials, and root through Docker. Nearly everything below is about closing that gap while keeping builds fast.
What a runner needs, and what it does not
A runner needs to check out code, execute build steps, and report back. It does not need access to your production hosts, your deployment credentials, or anything else on the machine.
That leads to one decision worth making up front: run the runner on a dedicated VPS, not on a box doing anything else. A separate machine is a boundary you get for free, and it means a compromised build cannot read what is not there. The smallest plan is enough for many build workloads, and you can resize when the workload tells you to.
Do not put a runner on a host that also runs your application, your database, or your deployment keys.
1. Create a dedicated account
Never run the service as root or as your own user.
sudo adduser --system --group --home /opt/actions-runner runner
sudo mkdir -p /opt/actions-runner
sudo chown runner:runner /opt/actions-runner
Give it no login shell and no sudo. If a build step needs privilege, that is a signal to reconsider the build step, not to grant sudo.
2. Install the runner
Fetch and unpack as the runner account. Check the current version and checksum on GitHub's runner releases page rather than copying a version from any guide, including this one.
sudo -u runner bash
cd /opt/actions-runner
curl -o actions-runner-linux-x64.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.999.0/actions-runner-linux-x64-2.999.0.tar.gz
echo "<checksum-from-the-release-page> actions-runner-linux-x64.tar.gz" | shasum -a 256 -c
tar xzf actions-runner-linux-x64.tar.gz
Configure it against the repository or organisation. The registration token is short-lived and comes from the repository settings under Actions, Runners:
./config.sh --url https://github.com/example/repo \
--token <registration-token> \
--name vps-runner-01 \
--labels self-hosted,linux,x64,vps \
--unattended
Labels are how workflows select this runner, so give it something specific enough to target deliberately:
jobs:
build:
runs-on: [self-hosted, vps]
3. Run it under systemd
The bundled svc.sh works, but a unit you wrote is a unit you understand, and it lets you add the confinement below.
Create /etc/systemd/system/actions-runner.service:
[Unit]
Description=GitHub Actions runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=runner
Group=runner
WorkingDirectory=/opt/actions-runner
ExecStart=/opt/actions-runner/run.sh
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/actions-runner
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now actions-runner
sudo systemctl status actions-runner
ProtectHome=true is doing real work here: it means a build cannot read /home even if something else is misconfigured.
4. Give every job a clean workspace
A self-hosted runner reuses its working directory by default. That is the single biggest behavioural difference from hosted runners, and it causes both flaky builds and a quiet security problem: one job can leave files, caches, or credentials that the next job reads.
Clean up after every job, in the workflow itself:
jobs:
build:
runs-on: [self-hosted, vps]
steps:
- uses: actions/checkout@v4
with:
clean: true
- name: Build
run: make build
- name: Clean workspace
if: always()
run: |
git clean -xdff
docker system prune -af --filter 'until=24h' || true
if: always() matters. A cleanup that only runs on success leaves the mess from exactly the runs most likely to have made one.
5. Keep builds off the Docker socket
Mounting /var/run/docker.sock into a build, or adding the runner to the docker group, is equivalent to giving that build root on the host. A container can be started with the host filesystem mounted, and from there nothing on the machine is protected.
If builds need containers, prefer rootless Docker or Podman owned by the runner account, so the daemon has no host privilege to give away:
sudo -u runner bash
dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
If you cannot avoid a privileged builder, isolate it on a VPS that does nothing else and treat that machine as disposable.
6. Keep secrets out of the runner
Two rules cover most of it.
Do not put deployment credentials on the runner. A build should produce an artifact. Something else, triggered separately, should deploy it. If the runner holds the key to production, then anyone who can open a pull request is one careless workflow away from holding it too.
Do not run untrusted pull requests. For public repositories, a fork's pull request can modify the workflow file itself. GitHub requires approval for first-time contributors by default; leave that on, and be deliberate about approving. For anything sensitive, restrict self-hosted runners to private repositories.
Also make secrets hard to exfiltrate by accident:
- name: Build
env:
API_TOKEN: ${{ secrets.API_TOKEN }}
run: ./build.sh
Scope the secret to the step that needs it rather than the whole job, and never echo one, even while debugging. Masked values still leak through base64, through error messages, and through files an artifact upload happens to include.
7. Stop builds filling the disk
This is the failure that actually happens, usually at the worst time. Container images, caches, and old workspaces accumulate until a build fails on a full disk, and then everything fails.
Watch it:
df -h /
docker system df
du -sh /opt/actions-runner/_work/* | sort -h | tail
Reclaim it on a schedule rather than hoping the per-job cleanup was enough. /etc/systemd/system/runner-gc.service:
[Unit]
Description=Reclaim CI build space
[Service]
Type=oneshot
User=runner
ExecStart=/usr/bin/docker system prune -af --filter until=168h
ExecStart=/usr/bin/find /opt/actions-runner/_work -maxdepth 2 -type d -mtime +7 -exec rm -rf {} +
With a timer next to it:
[Unit]
Description=Reclaim CI build space daily
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Alert on free space before it becomes an outage. A build host at 90 percent disk is a build host that will fail tonight.
8. Know how to remove it
Decommissioning matters as much as installing, because a forgotten runner still accepts jobs.
sudo systemctl disable --now actions-runner
sudo -u runner /opt/actions-runner/config.sh remove --token <removal-token>
Remove it from the repository settings as well, and confirm it no longer appears in the runner list. A runner that is offline in the UI but still registered will happily pick up work the moment someone restarts the box.
Diagnosing a self-hosted runner
| Symptom | Cause | Check |
|---|---|---|
| Jobs queue and never start | No runner matches the labels | Compare runs-on with the runner's labels |
| Runner offline after reboot | Service not enabled | systemctl is-enabled actions-runner |
| Build passes locally, fails on runner | Dirty workspace from a previous job | Add clean: true and git clean -xdff |
| Sudden failures across all jobs | Disk full | df -h / and docker system df |
| Permission denied on Docker | Rootless socket not set for the account | echo $DOCKER_HOST as the runner user |
| Job hangs at checkout | Network or credential problem | journalctl -u actions-runner -f |
| Registration fails | Token expired | Generate a new one; they are short-lived |
Checklist
- Runner is on a VPS that does nothing else.
- It runs as a dedicated system account with no sudo and no login shell.
- The systemd unit sets
ProtectHome,ProtectSystem, andNoNewPrivileges. - Every job cleans its workspace with
if: always(). - No build has access to a privileged Docker socket.
- Deployment credentials live somewhere the runner cannot read.
- Untrusted pull requests do not run without approval.
- A timer reclaims disk, and free space is monitored.
- You know the removal procedure and have used it once.
A self-hosted runner is worth having: no queue, no per-minute cost, and hardware you control. It is worth having safely, which mostly means deciding in advance what a build is allowed to touch, and putting it somewhere that touching it does not matter.
Continue reading