Proxmox Runner Disk Cleanup
Install, check and remove the self-cleaning setup on the two Linux
GitHub Actions runner VMs on thb2: gh-runner-1
(VMID 101) and gh-runner-2 (VMID 111). See Desktop Dev Hosts for the guests
themselves.
Use this runbook when:
- Rolling the cleanup out to a runner VM, or updating it after a change to the script
- Checking what is installed on a VM and in which mode
- The runner disk Slack alert fires, or a job fails with “Runner disk full”
- Removing the cleanup from a VM
What it does
One script, run from your laptop, installs the following on a VM:
| Piece | When | What it does |
|---|---|---|
| Job-started hook | Before every job on a slot | Reaps that slot’s leftovers (containers, networks), then guards free space: prunes if below the prune threshold, fails the job if below the fail threshold. Both act in enforce mode only; observe mode logs them |
| Job-completed hook | After every job on a slot | Reaps the slot again, removes the run’s images (tags containing
-<run_id>-<attempt>-) and the slot’s
runner-labelled images, and (ci profile) prunes dangling anonymous
volumes. Never fails the job |
| Hourly timer | Every hour | Reaps leftovers on idle slots. ci profile only: prunes old stopped containers and networks, dangling anonymous volumes, and caps the BuildKit cache |
| Nightly timer | 03:30 America/Chicago | Per idle slot: stale _work/<repo> dirs, stale
_tool versions, old _diag logs,
_work/_update, superseded bin.* and
externals.*. VM-wide: stale or oversized shared caches,
apport crash dumps. ci profile only: image prune under disk
pressure |
| Disk alert timer | Every 5 minutes | Posts to Slack when the root filesystem crosses the alert threshold, and again when it recovers |
Hooks run as runner and are wired into each slot’s
.env between # BEGIN proxmox-runner-cleanup
and # END proxmox-runner-cleanup. Timers run as root. Files
land in /usr/local/lib/proxmox-runner/,
/opt/runner-hooks/proxmox-runner-job-{started,completed}.sh,
/etc/proxmox-runner/ and
/etc/systemd/system/proxmox-runner-*.
Ownership: what counts as a slot’s leftovers
The reaper only touches containers and networks it can tie to a slot:
- Runner label. The Actions runner labels everything
it creates with the first 6 hex characters of the sha256 of the
lowercased contents of the slot’s
.runnerfile. The cleanup computes the same value. - CI label convention. A script that runs
docker createordocker runitself (the runner does not label those) should add--label com.thehelperbees.ci.runner=$RUNNER_NAME. Containers with that label are reaped by the slot of that name. - Compose working directory. Compose containers whose
com.docker.compose.project.working_diris under the slot’s_work/are reaped. A compose project’s networks and volumes are removed only when no container anywhere still carries that project label.
Never reaped: the nf-sync-lab compose project (it runs
from inside a gh-runner-1 slot’s _work) and
anything labelled com.thehelperbees.lifecycle=persistent.
Label long-lived containers that must live on a runner VM this way.
Profiles
ci (gh-runner-2) |
shared (gh-runner-1) |
|
|---|---|---|
| Slot-scoped container and network reap | yes | yes |
| Run-id image removal | yes | yes |
| Dangling anonymous volume prune | yes | no |
| Stopped container, network and BuildKit cache prune | yes | no |
| Nightly image prune under pressure | yes | no |
| File cleanup (work, tool, cache, diag) | yes | yes |
| Disk alert | yes | yes |
gh-runner-1 runs the thb2 lab on the same
Docker daemon as its CI slots, with named volumes and stopped containers
that must survive. The shared profile therefore never runs
a VM-wide docker image, builder,
volume or container prune.
docker volume prune --all is never used anywhere.
Known gap on gh-runner-1: service-container
volumes are not reclaimed. The Actions runner removes
services: containers (postgres, redis and other images that
declare a VOLUME) with docker rm --force and
no -v, so each such job leaves one anonymous volume per
service behind, after the job-completed hook can see it. Once its
container is gone, an anonymous volume carries no label tying it to a
slot, and on gh-runner-1 it cannot be told apart from
anonymous volumes the lab created (99 dangling ones were there on
2026-10-28, of unknown origin). The ci profile reclaims
them with docker volume prune; the shared
profile cannot do so safely, so they accumulate on
gh-runner-1, and the disk alert is the backstop. For scale,
gh-runner-2 gathered 86 of them (6.5 GiB) in five days
before cleanup existed; gh-runner-1 has 4 active slots
instead of 18. Two ways to close it: jobs that use
services: on the pool add
options: --tmpfs /var/lib/postgresql/data (postgres) or
--tmpfs /data (redis) so no volume is ever created, or the
lab’s owner confirms which dangling volumes are the lab’s and the rest
are removed by hand.
Thresholds
| Setting | Value | Applies to |
|---|---|---|
| Guard prune | below 40 GiB free at job start | both |
| Guard fail | below 15 GiB free at job start (enforce mode only) | both |
| Alert | root filesystem at or above 85% used; clears below 80% | both |
| BuildKit cache cap | 80 GiB max used, 50 GiB min free, always keeps 20 GiB (hourly) | ci only |
| Image prune | at or above 80% used, images first pulled more than 168h ago (nightly) | ci only |
Stale _work/<repo> |
no file modified in 7 days | both |
Stale _tool/<tool>/<version> |
no file accessed in 14 days (skipped on a noatime
mount) |
both |
| Stale shared cache entry | nothing accessed in 14 days | both |
| Oversized shared cache entry | over 15 GiB, removed only when the disk is at or above 80% used and no job is running anywhere on the VM; renamed aside before deleting so a starting job never sees a half-deleted toolchain | both |
_diag logs |
older than 14 days | both |
Prerequisites
- Office VPN. Connect WireGuard as
WireGuard-Server-1-AJ. The guests are only reachable throughthb2. - Root SSH to
thb2(192.168.2.6). The script drives each VM withssh root@192.168.2.6 qm guest exec <vmid>, so the VM needs no SSH key of its own. - A clean
known_hostsforthb2. The Mac’s~/.ssh/known_hostsusually holds a stale entry for192.168.2.6and the connection is refused. Build a file from the cluster’s copy and pass it with--known-hosts:
# On thb2 (web UI shell or an already-trusted session), print the host key line(s)
cat /etc/pve/nodes/thb2/ssh_known_hosts
# On your laptop, save them to a file, e.g. ~/thb2_known_hosts, then:
./zig/zig build scripts -- proxmox_runner_cleanup check --known-hosts ~/thb2_known_hostsRollout
Roll out one VM at a time, gh-runner-2 first because it
has the simpler profile and the more urgent problem.
Render and review (offline). Writes each host’s bundle and rendered config; touches nothing:
./zig/zig build scripts -- proxmox_runner_cleanup render --out /tmp/prc ./zig/zig build scripts -- proxmox_runner_cleanup render --out /tmp/prc --host gh-runner-2 --mode observeCheck the current state (read-only):
./zig/zig build scripts -- proxmox_runner_cleanup check --host gh-runner-2 --known-hosts ~/thb2_known_hostsInstall in observe mode. Everything is installed and the timers start at once. Idle slots get the hooks immediately; slots busy with a job show as
restart pendinguntil you rerun install (see Busy slots). Cleanup only logs “would …” lines and the guard never fails a job. Alerts still fire:./zig/zig build scripts -- proxmox_runner_cleanup install --host gh-runner-2 --mode observe \ --slack-webhook-file ~/runner-disk-webhook.txt --known-hosts ~/thb2_known_hostsRead the logs for about a day (see Logs). Confirm the “would” decisions only name leftovers from finished jobs, and nothing you want to keep.
Switch to enforce:
./zig/zig build scripts -- proxmox_runner_cleanup install --host gh-runner-2 --mode enforce \ --slack-webhook-file ~/runner-disk-webhook.txt --known-hosts ~/thb2_known_hostsRepeat steps 2 to 5 for
--host gh-runner-1. Extra care there: also confirm the lab containers andnf-sync-labnever appear in the “would remove” lines.
Optional on install: --snitch-url <URL> makes the
nightly run ping a Dead Man’s Snitch check, so a dead timer pages
instead of going unnoticed.
Busy slots and
restart pending
A slot only picks up the new hooks when its
actions.runner.* service restarts. Install restarts idle
slots and reports busy ones as restart pending. Rerun the
same install command once they are idle; it is idempotent.
check also lists pending restarts.
Slack webhook
The disk alert posts to a Slack incoming webhook. Create one for
#devops (Slack app settings, Incoming Webhooks), save the
URL to a local file, and pass it with --slack-webhook-file.
It is stored on the VM at
/etc/proxmox-runner/slack-webhook-url, readable only by
root.
CI jobs on these VMs have passwordless sudo, so a job can read that file. That is acceptable only because a webhook can do nothing but post to one channel. Never put a bot token or any broader Slack credential on these VMs. Without a webhook the alert only logs.
Logs
- In the job log. Hook output appears in the “Set up runner” step (job-started hook) and the “Complete runner” step (job-completed hook) of each job.
- Timers and alert, on the VM:
journalctl -u proxmox-runner-cleanup-hourly -u proxmox-runner-cleanup-nightly -u proxmox-runner-disk-alert --since "1 day ago"
systemctl list-timers 'proxmox-runner-*'Reach the VM through thb2:
ssh root@192.168.2.6, then
qm guest exec 111 -- bash -c '<command>' (use
101 for gh-runner-1). Observe-mode decisions
are the lines starting with would:.
When the alert fires or a job fails on disk
check --host <vm>fordf, the mode, timer states and per-slot container counts.- If the mode is
observe, nothing is being deleted. Switch toenforceonce the logs look right. - Look at the nightly and hourly journal for errors. Run the service
by hand on the VM if needed:
systemctl start proxmox-runner-cleanup-nightly. - If it is still full, find what is big
(
docker system df,du -xh --max-depth=2 /home/runner). Ongh-runner-1check the lab (/opt/thb2,/srv/thb2) before blaming CI; the cleanup does not touch it.
Uninstall
Per VM, as root (via qm guest exec). Detach the slots
before deleting the hook files, otherwise a slot that still points at a
missing hook fails its next job at “Set up runner”.
Stop and disable the timers:
systemctl disable --now proxmox-runner-cleanup-hourly.timer proxmox-runner-cleanup-nightly.timer proxmox-runner-disk-alert.timerIn every slot’s
.env(/home/runner/actions-runner*/.env), delete the managed block from# BEGIN proxmox-runner-cleanupthrough# END proxmox-runner-cleanup:sed -i '/^# BEGIN proxmox-runner-cleanup/,/^# END proxmox-runner-cleanup/d' /home/runner/actions-runner*/.envRestart the idle
actions.runner.*services so they drop the hook variables. Busy slots keep the old hooks until they restart, so restart them after their job finishes and do not continue until every slot has been restarted.Remove the units and files:
rm -f /etc/systemd/system/proxmox-runner-cleanup-{hourly,nightly}.{service,timer} \ /etc/systemd/system/proxmox-runner-disk-alert.{service,timer} \ /etc/tmpfiles.d/proxmox-runner*.conf rm -rf /usr/local/lib/proxmox-runner /etc/proxmox-runner /var/lib/proxmox-runner rm -f /opt/runner-hooks/proxmox-runner-job-started.sh /opt/runner-hooks/proxmox-runner-job-completed.sh systemctl daemon-reload
Run check afterwards to confirm nothing is left.