Self-hosted GitHub Actions runner
How to stand up a Linux machine that runs this project's GitHub Actions jobs on your own hardware instead of GitHub's.
⚠️ This repository's own CI does not need one. It is public, and GitHub does not bill standard runners in public repositories — see the readiness plan for the three properties that would have to be fixed first. This guide exists because private repositories are billed, and because a machine that can build the provision-host image is useful independently of who is paying.
Before you start: is a runner the right answer?
🔴 A self-hosted runner is a machine that executes code from a repository. That is the whole point and it is also the whole risk. Three questions decide the shape of everything below:
- What triggers the workflows? A runner serving only
pushandpull_requestsees code from people who can already push. A runner servingissue_commentorissuesexecutes on anything anyone can type into an issue. Those are not the same machine. - What credentials do the jobs hold? A job that deploys somewhere holds a credential that deploys somewhere, for the length of the job, on this machine. Workload-identity federation (OIDC) is much better than a stored key — the token is short-lived and minted per job — but it still exists on the runner while the job runs.
- Does anything it builds get consumed elsewhere? A machine that builds a container image other people run is a supply-chain position, not a build box.
⚠️ If the answers put "arbitrary issue text" and "deploys to production" on the same machine, use two machines. No amount of RAM fixes that, and one shared runner label silently undoes the separation however many machines exist.
Machine
| form | a VM, not a container, if it will build images — a shared kernel is a weaker boundary, and Docker inside an unprivileged container fights buildx |
| OS | Debian 13 (trixie) |
| vCPU | 4 for image builds; 2 is enough for lint-and-test workflows |
| RAM | 8 GiB for image builds — and see the warning below |
| disk | 80 GB if building multi-arch images; 20 GB otherwise |
| network | outbound HTTPS; no inbound — the runner polls GitHub |
🔴 If the VM has a memory balloon, pin it. A balloon that can squeeze the
guest below its nominal RAM will OOM buildx part-way through a layer, and
that does not look like memory pressure — it looks like a flaky build, on a
machine nobody is watching, intermittently. Set minimum = maximum, or disable
ballooning for this guest.
⚠️ A CPU cap is a fine trade; a memory cap is not. Slow builds cost minutes.
Intermittent OOM costs somebody a day on buildx.
Packages
apt-get update
apt-get install -y curl git jq ca-certificates
# plus whatever the workflows actually use — read them, do not guess
For image builds, additionally Docker CE with buildx, and binfmt handlers if
building for a foreign architecture.
🔴 docker/setup-qemu-action registers binfmt handlers system-wide and they
outlive the job. On GitHub's disposable VMs that is invisible. On a machine
that persists it is a lasting change to the host's binary-format registry, made
by a CI job. Know that it happens; it is not a reason to avoid it.
A user that owns nothing else
adduser --disabled-password --gecos "" ghrunner
The runner must not run as root, and must not share a home directory with anything holding a credential.
Install and register
sudo -u ghrunner -i
mkdir actions-runner && cd actions-runner
# Use the version and checksum from the repository's
# Settings > Actions > Runners page — do not copy them from a guide.
curl -o actions-runner-linux-x64.tar.gz -L <url>
echo "<sha256> actions-runner-linux-x64.tar.gz" | shasum -a 256 -c
tar xzf actions-runner-linux-x64.tar.gz
./config.sh --url https://github.com/<owner>/<repo> \
--token <registration token> \
--labels self-hosted,linux,<purpose> \
--unattended
🔴 The registration token is short-lived and repository-scoped. Do not store it anywhere. If it lands in a file, a log or a chat message, remove the runner and register again.
⚠️ Choose the purpose label deliberately. Labels are the isolation boundary, not just names: they are what stops a job triggered by an issue comment from landing on the machine that holds deploy credentials. A label that describes the wrong thing is worse than no label, because people trust it.
Survive reboot
cd /home/ghrunner/actions-runner
sudo ./svc.sh install ghrunner
sudo ./svc.sh start
⚠️ Verify by rebooting. systemctl is-enabled says a unit is supposed to
start. It does not say it will.
Workspace hygiene
The runner reuses its working directory between jobs. Unless each workflow says otherwise, state leaks from one job to the next:
- uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0 # a check that reads `git log -- <file>` compares the
# wrong commit under a shallow checkout
Verify — this is the part that matters
- Prove it with a manually dispatched job first.
workflow_dispatchtakes no external input, so you are testing the runner rather than exposing it. Point the externally-triggered workflows at it afterwards. - 🔴 Confirm the run's log names your runner. A job that quietly stayed on
ubuntu-latestlooks identical in a green tick. - Reboot the host and run it again.
- Run two jobs at once, if any workflow can overlap. Tools that create
named resources —
kindclusters, fixed host ports — collide on a shared runner where GitHub would have given each job its own VM. A collision is a race, and a race does not appear in a diff. - Check that the hosted minutes actually stopped. The point of the exercise is the bill; read it rather than inferring it from a job succeeding.
Administrative access
If the machine is firewalled to no inbound, it needs another way in before that rule is applied — otherwise the isolation locks out the people who operate it.
🔴 An overlay network such as Tailscale gives an admin path in and also gives the runner reach out across the whole overlay. On a machine that executes repository code, that can be a wider surface than the rule you were closing. Restrict it with ACLs so the runner is reachable but not reaching; if that cannot be expressed, keep the narrower inbound rule instead.
Ephemeral runners
Every isolation problem above — persistent credentials, binfmt handlers that outlive the job, workspace leaking between jobs, concurrent jobs colliding — is a property of the runner persisting. A runner created for one job and destroyed afterwards removes most of them.
That is more moving parts than a single machine, and it is the right end state. Get one persistent runner working first, then decide.