A Self-Hosted GitHub Actions Runner with Docker: The Setup That Survives Billing
A self-hosted GitHub Actions runner is the official Actions agent installed on a machine you own. The minutes that run on it cost nothing: “GitHub Actions usage is free for self-hosted runners,” says the billing documentation (checked 2026-08-25). The setup that works on a small VPS fits in five decisions. The runner runs as a systemd service under an ordinary, unprivileged user. Every build tool runs in a Docker container, never installed on the host. Each repository gets its own runner, with its own label. The deploy gets root through exactly one script, authorized in sudoers. And only private repositories point at it: GitHub’s hardening documentation says to “almost never” use self-hosted runners on public repositories.
We did not arrive at that design out of elegance. We arrived there because the account’s hosted-minutes quota ran out and the entire CI started dying silently. The anatomy of that blackout (what stopped, what froze, and what nobody saw) is in the sibling post, when GitHub Actions dies on billing. This one is the guide to what was left standing: the design that now runs CI and deploys for three repositories on a single VPS.
What is a self-hosted GitHub Actions runner?
It is the official runner, the same program that runs on GitHub’s machines, installed on a machine you control. It polls: it asks GitHub whether there is a job for the repository it was registered in. The job lands on it when the workflow, the file that describes that job, declares the right labels:
jobs:
ci:
runs-on: [self-hosted, my-repo]
Two practical facts, both checked against the documentation on 2026-08-25:
- The machine only needs outbound HTTPS on port 443 (communication requirements). No inbound port. That fits a VPS with zero open ports behind Cloudflare Tunnel: the runner talks outward, like everything else on the box.
- The minutes meter only exists for hosted runners in private repositories (2,000 min/month on the Free plan). Windows and macOS minutes are priced higher than Linux ones. Self-hosted runners are free; public repositories on standard hosted runners are also free (billing). That price table decides this whole post’s topology on its own. And the security section shows it coincides with the safe topology.
How do you install the runner on a VPS as a service?
The whole path is your repository’s own page: Settings → Actions → Runners → New self-hosted runner. It generates the exact download and registration commands, with the current version and a short-lived registration token. That token only serves ./config.sh and never becomes a long-lived secret on the machine. After registration, the runner keeps its own credentials. What the page does not decide for you is what matters:
# a dedicated, ordinary user, with NO sudo — the runner does not run as root
sudo useradd -m runner
sudo -iu runner
mkdir actions-runner && cd actions-runner
# (download + checksum: copy from the repo's "New self-hosted runner" page)
./config.sh --url https://github.com/<owner>/<repo> --token <token-from-the-page> \
--labels my-repo
exit
# systemd service, installed for THAT user — survives reboots
cd /home/runner/actions-runner
sudo ./svc.sh install runner
sudo ./svc.sh start
svc.sh install <user> / svc.sh start is the official mechanism for running the runner as a systemd service, the kind the system brings back up on its own (doc checked 2026-08-25). The same page warns that on Debian/Ubuntu with needrestart enabled, you must configure it to ignore the runner’s service. Otherwise, any routine upgrade restarts the runner mid-job.
What not to do shows up right here: do not run the runner as root, do not give its user general sudo. The only privilege it will ever get is the one in the deploy section: one script, and nothing else.
Why run every tool in a Docker container, and nothing on the host?
The host keeps the minimum: the runner, docker, git. Node, Python, shellcheck all come in an image, chosen per workflow. The gain is not theoretical. Each repository keeps its own version, without conflict. No apt-get accumulating state on a production box. And the build environment dies with the container instead of becoming archaeology.
One piece of honesty before the patterns: the container here is hygiene, not a sandbox. The workflow controls the docker commands, so whoever writes to the repository can mount whatever they want. The security boundary is elsewhere, in the public-repository section below. The container solves state and versions, not trust.
We use two patterns, and the difference between them is how many steps share state:
Pattern A: one big step, read-only checkout. The checkout is mounted read-only at /src and copied inside the container. The container may be root inside, because nothing flows back into the runner’s workspace:
- run: |
docker run --rm -v "$GITHUB_WORKSPACE":/src:ro -w /repo node:22 \
bash -eo pipefail -c '
cp -a /src/. /repo/
git config --global --add safe.directory /repo
npm ci --no-audit --no-fund
npm test'
Two gotchas that cost real runs. The first: the copied .git belongs to the runner’s user, and inside the container you are root. Without the safe.directory, any git command dies with detected dubious ownership. And this does not reproduce on macOS Docker, which maps the ownership of bind mounts, the host folders mounted into the container; only the Linux runner shows it. The second is the image: use node:22 instead of node:22-slim whenever npm ci compiles a native addon (needs python3/make/g++) or some step walks the git history.
Pattern B: several steps sharing state, read-write mount with --user. When steps pass node_modules and dist between one another, copying on every step won’t do. The mount becomes read-write and the container runs with the runner user’s uid:gid. That way no root-owned file is left in the workspace, and root-owned files in the workspace are what break the next run’s npm ci:
env:
DOCKER_NODE: >-
docker run --rm
--user 1000:1000
-e HOME=/tmp
-e npm_config_cache=/w/.npm-cache
-v ${{ github.workspace }}:/w -w /w
node:22
steps:
- uses: actions/checkout@v4
- run: $DOCKER_NODE npm ci --no-audit --no-fund
- run: $DOCKER_NODE npx astro check
- run: $DOCKER_NODE npm run build
Three gotchas in this pattern, all paid for in red runs. The first: $(id -u) does not expand inside an env: value, because YAML gets no command substitution there. The runner user’s uid:gid goes in fixed.
The second is HOME=/tmp: npm demands a writable HOME, and the runner user’s home does not exist inside the container. The third: the npm cache lives inside the workspace, not in a named volume. A named volume is born owned by root, the container runs as an ordinary user, and npm dies with error writing to the directory. In the workspace the owner is right, and the cache still survives across runs.
The selection rule is short: a single verification, pattern A; steps that share artifacts, pattern B.
One repository, one runner: why three runners on the same box?
Because on a personal account there is no choice. A runner registered in a repository serves only that repository. Sharing runners across repositories is an organization feature: runner groups exist at the organization level (checked 2026-08-25), not on personal accounts.
The result here: three live repositories, three runners on the same VPS, three systemd services to keep updated. It is a conscious cost, written down in the PR that created it. Rediscovering in six months why the box has three near-identical units would cost more.
Two side effects worth knowing in advance:
- Labels are a conjunction, not alternatives.
runs-on: [self-hosted, my-repo]requires a runner carrying both labels. Giving each runner a distinctive label documents where the job should land. And it protects against the day a second runner shows up in the same repository. - A runner executes one job at a time. That serializes deploys for free: two pushes cannot trample each other. And it charges the mirror price: running the same suite in two workflows that fight over the same runner means paying twice, in a queue. That is exactly why we deduplicated the test job that ran on push and again inside the deploy pipeline.
What does deploy-with-rollback look like?
The runner lives on the production box. So “deploy” stops being ssh and becomes running a local script, gated by CI:
deploy:
needs: build-and-test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: [self-hosted, my-repo]
timeout-minutes: 30
concurrency:
group: deploy
cancel-in-progress: false
The privilege boundary is the heart of the design. The runner’s user has no sudo, except for one script, owned by root, outside the checkout:
# the script is installed by root, outside the directory the runner writes to
install -o root -g root -m 0755 deploy/ci-deploy.sh /opt/app/ci-deploy.sh
echo 'runner ALL=(root) NOPASSWD: /opt/app/ci-deploy.sh' > /etc/sudoers.d/ci-deploy
chmod 440 /etc/sudoers.d/ci-deploy && visudo -c
“Outside the checkout” is not fussiness. sudoers is the file that says who may run what as root. If it pointed at a file the runner’s user can write, the “restricted sudo” would be general root with extra steps. And the resulting trust model deserves saying out loud: whoever writes to main deploys. Protect main, keep the test job in front, and accept that this is the real boundary.
The script itself follows the classic playbook: snapshot the current state, sync the tested checkout, up -d --build --wait, deep healthcheck (the service’s own health check). If either leg fails, the rollback takes over: back to the snapshot. Three details of the surrounding workflow were paid for in lessons:
- Read the whole script into memory before executing it (
bash -c "$(cat /opt/app/redeploy.sh)"). A deploy that updates its own deploy script must not pull the rug out from under bash mid-run. timeout-minutescovers both legs. The worst case is build + healthcheck wait and then a rollback rebuild + another wait. A timeout that only fits the forward leg kills the rollback halfway, which is exactly when you need it most.cancel-in-progressis good for CI and terrible for deploys. Cancelling a branch’s superseded run saves queue time; cancelling a redeploy in the middle of--waitcan leave an unhealthy container up with no rollback. The deploy’s concurrency group serializes without cancelling.
Is a self-hosted runner safe on a public repository?
No. And that is not our opinion, it is the official documentation’s position. GitHub’s hardening guide, the platform’s own security page, was checked on 2026-08-25. It states: “Self-hosted runners should almost never be used for public repositories on GitHub, because any user can open pull requests against the repository and compromise the environment” (security hardening). The mechanism is direct: a fork PR brings code, and workflows are code, that executes on your machine.
And the damage does not end with the job. The default runner is not ephemeral: the same doc warns that the environment persists across jobs and can be “persistently compromised by untrusted code.” It also warns that secrets passed as command-line arguments are visible to another job on the same machine (a ps x -w is enough). Once dirty, the runner stays dirty for every job that follows.
Mitigations exist, and it is worth naming what each one is worth:
- Manual approval for fork PR runs. GitHub may require a maintainer’s approval before running workflows from forks. It shrinks the window, but it does not change the nature of the problem: one distracted click on “Approve and run” still executes a stranger’s code on your box.
- Ephemeral / just-in-time runners. They are registered via the API to “perform at most one job before being automatically removed.” They solve persistence across jobs. They do not solve the malicious job itself, which runs with the machine’s network access.
Which is why the recommendation stays simple: self-hosted runners only on private repositories. And note that the Docker from the earlier section changes nothing here. The workflow controls the docker commands, so the container is state hygiene, not protection against the repository itself.
Here the rule closes with a symmetry the price table had already hinted at. The only public repository with CI, the n8n community node, stayed on ubuntu-latest. Public repositories consume no quota on standard hosted runners: there was no billing reason to migrate, and there was a security reason not to. Private with a quota → self-hosted runner; public → hosted runner, for free.
The hardening doc says to ask a question that deserves a written answer in your repository: what sensitive information lives on the runner’s machine, and which services can it reach over the network? Our answer is “everything, it is the production box,” on purpose. That is what makes local deploys possible. The honest consequence of that choice: writing to main is equivalent to owning the box. A protected main is part of the design, not decoration.
What should never run on the self-hosted runner?
Beyond any public repository’s workflows, one whole category: whatever watches the box itself. The uptime probe, which watches whether the box is up, could not follow the other workflows onto the runner. The runner is the machine being watched, and a machine cannot report its own outage. That part of the incident, and the way out (moving the probe to the edge, away from both the box and GitHub), are told in the sibling post. The rule that remains is the same one from there: migrate builds and tests to the runner, never the alarm.
How do you avoid being caught by the quota again?
The self-hosted runner takes the critical workflows off the meter, but it does not turn the meter off. Any workflow that drifts back to ubuntu-latest in a private repository depends on the quota again. And the quota, as the incident proved, runs out without warning.
Our answer is a quota guard: a daily script that measures the month’s consumption and warns at 80%, before the blackout. The pattern matters more than the code:
- Measure through the billing API, with the token you already have.
gh apiagainst the current usage endpoint (/users/<you>/settings/billing/usage, summing the month’s Actions minutes), with a fallback to the legacy endpoint (/users/<you>/settings/billing/actions, which returnstotal_minutes_usedready-made). Both require theuserscope on the gh token: one interactivegh auth refresh -h github.com -s user, once. No new secret enters anything. - Three exit states, not two. OK, warning and “could not measure.” The third is what separates this design from a silent gate. With no network, no scope, or no
ghon the machine, the script says it did not measure instead of going quiet. Measurement failure treated as success is the same bug as CI dying in 2 seconds, re-enacted in shell. - Warn where someone already reads. The output line goes into the nightly backup routine’s log, which already has a reader and already ends by pinging a dead-man switch. A warning in a log nobody opens is just the old silence with a timestamp. Hanging the guard on an already-watched routine is what makes it exist.
- Warn immediately once you are already paying. If the month shows charges beyond the included minutes, the warning fires regardless of the percentage. At that point it is not a forecast, it is an invoice.
The summary you take home
- Self-hosted runner minutes are free, and the machine only needs outbound HTTPS on 443. It fits on a VPS with zero open ports (documentation checked 2026-08-25).
- Install it as a service: dedicated user without sudo,
./config.shwith the registration token from the repo’s page,sudo ./svc.sh install <user> && sudo ./svc.sh start. Andneedrestartignoring the service on Debian/Ubuntu. - Tools in containers, host kept clean: read-only checkout + copy when the job is one big step. Read-write mount with
--user(fixed uid, because$( )does not expand inenv:),HOME=/tmpand the cache inside the workspace when steps share state. A container is hygiene, not a sandbox. - Personal account = one runner per repository (runner groups are an organization feature). A distinctive label per runner; one job at a time, which buys serialization for free and suite deduplication as an obligation.
- Deploy with rollback: the runner runs a local root-owned script authorized by a single sudoers line, outside the checkout. A timeout that covers both the forward leg and the rollback; deploys serialize without
cancel-in-progress. Whoever writes tomaindeploys. Protectmain. - Public repositories do not point at self-hosted runners. The official doc says “almost never,” because a fork PR executes code on your machine and the environment persists across jobs; manual approval and ephemeral runners mitigate, not solve. Public repos run for free on hosted runners. Use that.
- The alarm does not live on the box: the uptime probe stays off the runner, because a machine cannot report its own outage.
- Quota guard: measure the month through the billing API, warn at 80% and the moment charges appear, distinguish “did not measure” from “OK,” and log where someone already reads. The quota runs out without warning; the warning is something you build.
Infrastructure that scales without breaking the bank
Cloud bill out of control? I run my own on a single VPS with no open ports, automatic deploys and healthcheck-gated rollback. The whole design is published here.
Read the infrastructure posts →