Stand up a small fleet of GitHub Actions self-hosted runners on one Linux box, provisioned so heavy CI jobs don't OOM the machine or fill its disk.
It's three short, idempotent scripts plus a config file:
| Script | What it does |
|---|---|
provision-box.sh | Host prep: per-runner swap, low swappiness, per-runner /scratch, and a /tmp+scratch reaper. |
install-runners.sh | Downloads the runner, registers N runners against your repo/org, installs them as systemd services. |
provision-postgres.sh | Optional: one small native Postgres for CI to share, with per-run schema isolation. |
check-runners.sh | Health check — how many runners are online vs expected. GREEN / RED / DARK. |
Everything is parameterized through config.env — set your repo, runner count,
swap size, and labels there. Nothing here is specific to any one project.
The reference box below is an 8 GB machine. On that class of box the guidance is:
ci-1 / ci-2 / ci-3).Two easy ways to enforce the 2-concurrent cap (pick one; it's your call — bigger box, bump both numbers):
concurrency group with a limit, or
only apply two of the three runner labels in your runs-on rotation.systemctl start two of the services, leaving
the third stopped-but-registered as the spare.RUNNER_COUNT controls how many get provisioned. The concurrency cap is a policy you
apply in your workflows — this repo doesn't force one.
The reference deployment is a single Hetzner Cloud VM. Observed configuration:
| Provider | Hetzner Cloud |
| Class | Shared-vCPU AMD (CPX line) — comparable to CPX31 or larger |
| vCPU | 4 (AMD EPYC) |
| RAM | 8 GB |
| Disk | ~75 GB SSD |
| OS | Ubuntu 24.04 LTS |
| Swap | 3 × 10 GB (one swapfile per runner, ~30 GB) — set up by provision-box.sh |
| Container runtime | Docker 29.x (jobs that build/run containers) |
| Node | 22.x (only if your jobs need it) |
| Runner user | non-root deploy, in the sudo and docker groups |
The box runs nothing but the runners — no app, no database. Keep it that way; a CI box that also serves traffic is where the OOM surprises come from.
Create the server. In the Hetzner Cloud console (or hcloud): a CPX31
(4 vCPU / 8 GB / 160 GB) or larger, image Ubuntu 24.04, in a region near
your team. Add your SSH key at create time.
# with the hcloud CLI:
hcloud server create --name ci-runner --type cpx31 --image ubuntu-24.04 --ssh-key YOUR_KEY
Base packages + Docker. SSH in as root and:
apt-get update && apt-get install -y curl ca-certificates git jq
# Docker (official convenience script):
curl -fsSL https://get.docker.com | sh
A non-root runner user with sudo + docker, and passwordless sudo (svc.sh needs it):
adduser --disabled-password --gecos "" deploy
usermod -aG sudo,docker deploy
echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy && chmod 440 /etc/sudoers.d/deploy
(Optional) Node, if your jobs run it directly on the box rather than in a container:
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs
Then run this repo (below).
git clone https://github.com/OWNER/self-hosted-ci-runner.git
cd self-hosted-ci-runner
cp config.env.example config.env
$EDITOR config.env # set GITHUB_URL, RUNNER_COUNT, RUNNER_USER, etc.
# 1. Provision the host (swap / scratch / reaper). Run as root, ON the box:
sudo ./provision-box.sh
# 2. Get a short-lived registration token (expires ~1h; needs gh auth or a PAT):
export REGTOKEN="$(gh api -X POST repos/OWNER/REPO/actions/runners/registration-token --jq .token)"
# 3. Install + register + start the runners. Run as your RUNNER_USER (e.g. deploy):
./install-runners.sh
# 3b. (Optional) if your tests need Postgres — one small shared instance:
sudo ./provision-postgres.sh
# 4. Verify (from anywhere with gh access to the repo/org):
./check-runners.sh
That's it — the runners show up under Settings → Actions → Runners and pick up
any workflow with runs-on: [self-hosted] (or your custom label).
provision-box.sh is idempotent (safe to re-run) and configures:
/swapfile1..N, SWAP_GB each), enabled and
persisted in /etc/fstab. On a memory-constrained box this is the difference
between a job that swaps briefly and a job that gets OOM-killed.vm.swappiness low (default 10), persisted in /etc/sysctl.d/ — swap is
insurance, not the default path./scratch/N with a systemd drop-in that sets TMPDIR/TMP
for that runner's service, so runners don't fight over one shared /tmp./etc/tmpfiles.d/runner-scratch.conf ages /tmp and every
/scratch/N at REAP_AGE (default 6h) using the stock systemd-tmpfiles-clean.timer.
Dead job-workspace dirs are the usual cause of a slow creep to
"No space left on device"; this sweeps them.If your suite needs Postgres, the naive setup — one shared instance every job writes into — means parallel runs clobber each other's data and exhaust connections. The other extreme, a fresh Postgres container per job, is heavier and slower than it needs to be.
What's here instead:
provision-postgres.sh installs one small native Postgres on the box
(default shared_buffers=128MB — deliberately tiny) with a role that owns a single
database. All runners share it.examples/ci-per-run-schema.yml shows the pattern:
a run creates run_<id>_<attempt>, points search_path at it via DATABASE_URL,
and drops it in an if: always() cleanup step so a crashed run can't leave schemas
piling up. Because the role owns the database, CREATE SCHEMA needs no extra grants,
and per-run schemas make concurrent runs safe without a container per job.Tune it in config.env (PG_USER, PG_DB, PG_SHARED_BUFFERS, PG_MAX_CONNECTIONS).
Keep it small: on a constrained box, a lean shared instance beats N heavy ones.
All knobs live in config.env (copied from config.env.example). Key ones:
GITHUB_URL — repo or org URL the runners register against.RUNNER_COUNT — how many runners (and how many swapfiles / scratch dirs).RUNNER_NAME_PREFIX / RUNNER_LABELS — naming + labels; each runner also gets a
unique <prefix>-<n> label so a workflow can target one exact runner.RUNNER_USER — the account the services run as (needs passwordless sudo + docker group).SWAP_GB, SWAPPINESS, REAP_AGE — host tuning.RUNNER_VERSION — pin the actions/runner release.provision-box.sh's reaper handles workspace silt. If your jobs also pile up Docker
build cache or package caches, examples/disk-hygiene.yml is a drop-in scheduled
workflow that reclaims those when a runner's disk crosses a threshold. Copy it into
your repo's .github/workflows/.
REGTOKEN is a short-lived registration token (~1h), never a PAT. It's passed
as an environment variable at install time and never written to disk by these
scripts. Don't commit it, don't paste it into config.env..runner / .credentials files. They hold
the runner's live auth. .gitignore already excludes them, the runner tarball, and
_work / _diag.PG_PASSWORD is a local-only CI credential, not a secret to guard —
but only because the box keeps Postgres bound to localhost and never exposes 5432.
Keep it that way; a self-hosted CI DB should not be reachable from the internet.MIT — see LICENSE. Use it, fork it, adapt it.
2 commits
Shell
100.0%
Stand up a small fleet of GitHub Actions self-hosted runners on one Linux box, provisioned so heavy CI jobs don't OOM the machine or fill its disk.
It's three short, idempotent scripts plus a config file:
| Script | What it does |
|---|---|
provision-box.sh | Host prep: per-runner swap, low swappiness, per-runner /scratch, and a /tmp+scratch reaper. |
install-runners.sh | Downloads the runner, registers N runners against your repo/org, installs them as systemd services. |
provision-postgres.sh | Optional: one small native Postgres for CI to share, with per-run schema isolation. |
check-runners.sh | Health check — how many runners are online vs expected. GREEN / RED / DARK. |
Everything is parameterized through config.env — set your repo, runner count,
swap size, and labels there. Nothing here is specific to any one project.
The reference box below is an 8 GB machine. On that class of box the guidance is:
ci-1 / ci-2 / ci-3).Two easy ways to enforce the 2-concurrent cap (pick one; it's your call — bigger box, bump both numbers):
concurrency group with a limit, or
only apply two of the three runner labels in your runs-on rotation.systemctl start two of the services, leaving
the third stopped-but-registered as the spare.RUNNER_COUNT controls how many get provisioned. The concurrency cap is a policy you
apply in your workflows — this repo doesn't force one.
The reference deployment is a single Hetzner Cloud VM. Observed configuration:
| Provider | Hetzner Cloud |
| Class | Shared-vCPU AMD (CPX line) — comparable to CPX31 or larger |
| vCPU | 4 (AMD EPYC) |
| RAM | 8 GB |
| Disk | ~75 GB SSD |
| OS | Ubuntu 24.04 LTS |
| Swap | 3 × 10 GB (one swapfile per runner, ~30 GB) — set up by provision-box.sh |
| Container runtime | Docker 29.x (jobs that build/run containers) |
| Node | 22.x (only if your jobs need it) |
| Runner user | non-root deploy, in the sudo and docker groups |
The box runs nothing but the runners — no app, no database. Keep it that way; a CI box that also serves traffic is where the OOM surprises come from.
Create the server. In the Hetzner Cloud console (or hcloud): a CPX31
(4 vCPU / 8 GB / 160 GB) or larger, image Ubuntu 24.04, in a region near
your team. Add your SSH key at create time.
# with the hcloud CLI:
hcloud server create --name ci-runner --type cpx31 --image ubuntu-24.04 --ssh-key YOUR_KEY
Base packages + Docker. SSH in as root and:
apt-get update && apt-get install -y curl ca-certificates git jq
# Docker (official convenience script):
curl -fsSL https://get.docker.com | sh
A non-root runner user with sudo + docker, and passwordless sudo (svc.sh needs it):
adduser --disabled-password --gecos "" deploy
usermod -aG sudo,docker deploy
echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy && chmod 440 /etc/sudoers.d/deploy
(Optional) Node, if your jobs run it directly on the box rather than in a container:
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs
Then run this repo (below).
git clone https://github.com/OWNER/self-hosted-ci-runner.git
cd self-hosted-ci-runner
cp config.env.example config.env
$EDITOR config.env # set GITHUB_URL, RUNNER_COUNT, RUNNER_USER, etc.
# 1. Provision the host (swap / scratch / reaper). Run as root, ON the box:
sudo ./provision-box.sh
# 2. Get a short-lived registration token (expires ~1h; needs gh auth or a PAT):
export REGTOKEN="$(gh api -X POST repos/OWNER/REPO/actions/runners/registration-token --jq .token)"
# 3. Install + register + start the runners. Run as your RUNNER_USER (e.g. deploy):
./install-runners.sh
# 3b. (Optional) if your tests need Postgres — one small shared instance:
sudo ./provision-postgres.sh
# 4. Verify (from anywhere with gh access to the repo/org):
./check-runners.sh
That's it — the runners show up under Settings → Actions → Runners and pick up
any workflow with runs-on: [self-hosted] (or your custom label).
provision-box.sh is idempotent (safe to re-run) and configures:
/swapfile1..N, SWAP_GB each), enabled and
persisted in /etc/fstab. On a memory-constrained box this is the difference
between a job that swaps briefly and a job that gets OOM-killed.vm.swappiness low (default 10), persisted in /etc/sysctl.d/ — swap is
insurance, not the default path./scratch/N with a systemd drop-in that sets TMPDIR/TMP
for that runner's service, so runners don't fight over one shared /tmp./etc/tmpfiles.d/runner-scratch.conf ages /tmp and every
/scratch/N at REAP_AGE (default 6h) using the stock systemd-tmpfiles-clean.timer.
Dead job-workspace dirs are the usual cause of a slow creep to
"No space left on device"; this sweeps them.If your suite needs Postgres, the naive setup — one shared instance every job writes into — means parallel runs clobber each other's data and exhaust connections. The other extreme, a fresh Postgres container per job, is heavier and slower than it needs to be.
What's here instead:
provision-postgres.sh installs one small native Postgres on the box
(default shared_buffers=128MB — deliberately tiny) with a role that owns a single
database. All runners share it.examples/ci-per-run-schema.yml shows the pattern:
a run creates run_<id>_<attempt>, points search_path at it via DATABASE_URL,
and drops it in an if: always() cleanup step so a crashed run can't leave schemas
piling up. Because the role owns the database, CREATE SCHEMA needs no extra grants,
and per-run schemas make concurrent runs safe without a container per job.Tune it in config.env (PG_USER, PG_DB, PG_SHARED_BUFFERS, PG_MAX_CONNECTIONS).
Keep it small: on a constrained box, a lean shared instance beats N heavy ones.
All knobs live in config.env (copied from config.env.example). Key ones:
GITHUB_URL — repo or org URL the runners register against.RUNNER_COUNT — how many runners (and how many swapfiles / scratch dirs).RUNNER_NAME_PREFIX / RUNNER_LABELS — naming + labels; each runner also gets a
unique <prefix>-<n> label so a workflow can target one exact runner.RUNNER_USER — the account the services run as (needs passwordless sudo + docker group).SWAP_GB, SWAPPINESS, REAP_AGE — host tuning.RUNNER_VERSION — pin the actions/runner release.provision-box.sh's reaper handles workspace silt. If your jobs also pile up Docker
build cache or package caches, examples/disk-hygiene.yml is a drop-in scheduled
workflow that reclaims those when a runner's disk crosses a threshold. Copy it into
your repo's .github/workflows/.
REGTOKEN is a short-lived registration token (~1h), never a PAT. It's passed
as an environment variable at install time and never written to disk by these
scripts. Don't commit it, don't paste it into config.env..runner / .credentials files. They hold
the runner's live auth. .gitignore already excludes them, the runner tarball, and
_work / _diag.PG_PASSWORD is a local-only CI credential, not a secret to guard —
but only because the box keeps Postgres bound to localhost and never exposes 5432.
Keep it that way; a self-hosted CI DB should not be reachable from the internet.MIT — see LICENSE. Use it, fork it, adapt it.
2 commits
Shell
100.0%