From 3018a990ba463ab467c638118c055acfb638dcd5 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Fri, 3 Jul 2026 00:55:36 -0700
Subject: [PATCH] fix(selfhost): keep runner temp files off docker overlay
Points TMPDIR/TMP/TEMP for the self-hosted GitHub Actions runner
service at a subdirectory of its mounted runner-work volume instead
of the container's plain /tmp, which lives in Docker's overlay/
containerd snapshot storage and grows the host's Docker root
storage directly (invisible to volume-scoped cleanup).
A one-shot runner-tmp-init service bootstraps that directory on the
volume (mkdir -p + world-writable chmod) before the runner starts,
gated on depends_on: condition: service_completed_successfully, so
this works on a fresh deploy with no manual steps and never touches
the runner image's own entrypoint. The temp-env values live in a
reusable x-runner-tmp-env anchor so additional runner services in a
multi-runner override follow the same pattern.
Also documents why runner temp storage must stay off overlay, a
copy-pasteable multi-runner override snippet, and safe Docker
cleanup guidance (containers/images/build cache only, never
volumes) in the self-hosting operations docs.
---
.../routes/docs.self-hosting-operations.tsx | 64 +++++++++
docker-compose.override.yml.example | 10 ++
docker-compose.yml | 35 +++++
...-selfhost-operations-runner-tmpdir.test.ts | 74 ++++++++++
.../selfhost-compose-runner-tmpdir.test.ts | 129 ++++++++++++++++++
5 files changed, 312 insertions(+)
create mode 100644 test/unit/docs-selfhost-operations-runner-tmpdir.test.ts
create mode 100644 test/unit/selfhost-compose-runner-tmpdir.test.ts
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
index 6b36b12475..0a966b3a86 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
@@ -154,6 +154,70 @@ sudo systemctl enable --now gittensory-docker-prune.timer`}
Run it manually at any time with docker system df before and after to see what
it reclaimed: sh scripts/selfhost-docker-prune.sh.
+ This should always prune containers, images, and build cache — never + volumes. Pruning a volume deletes real application state (the database, backups, vector + index, or a runner's registration and job data), not disposable build output, so it is + never part of routine cleanup unless you intentionally want to delete that state. +
+ +
+ If you run --profile runners, keep every runner job's scratch/temp writes
+ on the mounted runner-work volume, never the container's plain{" "}
+ /tmp. A container's own /tmp lives in Docker's
+ overlay/containerd snapshot storage — a CI job that writes high-volume temp data there
+ (language toolchain caches, build artifacts, ad hoc mktemp calls) grows the
+ host's Docker root storage directly, not the volume, so it is invisible to
+ volume-scoped cleanup and can fill the disk out from under the whole stack. The shipped{" "}
+ runner service points TMPDIR, TMP, and{" "}
+ TEMP at /tmp/runner/tmp (a subdirectory of the mounted{" "}
+ runner-work volume) and keeps RUNNER_WORKDIR at{" "}
+ /tmp/runner on the same volume. A one-shot runner-tmp-init service
+ creates that directory on the volume (and makes it world-writable, matching real{" "}
+ /tmp permissions) before the runner container starts, so this works out of the
+ box on a fresh volume with no manual steps.
+
+ Adding a second or third runner service in docker-compose.override.yml for
+ higher CI throughput? Each one needs its own runner-work-style volume, its own
+ init step, and the same temp env — YAML anchors don't cross separate compose files, so
+ repeat the extension block in your override file:
+
diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example
index 83f7070091..6976d6b23e 100644
--- a/docker-compose.override.yml.example
+++ b/docker-compose.override.yml.example
@@ -34,6 +34,16 @@
# The RECOMMENDED alternative to all of this: don't co-locate runners with the review stack at all -- use
# GitHub-hosted CI, or a separate dedicated runner host (see the runner service's comment in
# docker-compose.yml).
+#
+# SCALING TO MULTIPLE RUNNERS: adding a second/third runner service here (e.g. to raise CI throughput)?
+# Each one MUST set the same TMPDIR/TMP/TEMP env (pointed at its own mounted runner-work-style volume,
+# never left at the container's plain /tmp) and mount its own named volume at /tmp/runner -- otherwise its
+# CI job temp writes fall back to the container's overlay storage (the exact growth/disk-pressure problem
+# this repo's runner service is set up to avoid). YAML anchors don't cross separate compose files, so you
+# cannot reference docker-compose.yml's `x-runner-tmp-env` anchor from this file directly -- either repeat
+# the three env vars literally, or redeclare the SAME `x-runner-tmp-env: &runner-tmp-env` extension block
+# at the top of your own override file and merge it with `<<: *runner-tmp-env`. See the self-hosting
+# operations docs for a complete, copy-pasteable multi-runner service snippet.
services:
gittensory:
diff --git a/docker-compose.yml b/docker-compose.yml
index a93e6d4dd6..19c4a768a8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -41,6 +41,23 @@ x-logging: &default-logging
max-size: "10m"
max-file: "3"
+# Self-hosted GitHub Actions runner temp env (#selfhost-runner-tmp): a container's plain /tmp lives in
+# Docker's overlay/containerd snapshot storage, NOT any mounted volume -- high-volume CI scratch writes
+# there (language toolchains, build caches, ad hoc `mktemp` calls) grow the HOST's Docker root storage
+# directly and are invisible to volume-scoped cleanup (confirmed in production: unbounded overlay growth,
+# disk pressure, and `docker system df` races against disappearing temp files). Every self-hosted-runner
+# service -- the `runner` service below, merged in via `<<: *runner-tmp-env`, AND any additional runner
+# service you add for a multi-runner deployment -- must point TMPDIR/TMP/TEMP at its own mounted
+# runner-work-style volume the same way, so job temp writes always land on mounted storage instead of the
+# container's writable layer. A second compose file (e.g. docker-compose.override.yml) CANNOT reference
+# this anchor directly -- YAML anchors don't cross files -- so an override adding another runner service
+# must redeclare this same x-runner-tmp-env block in its own file before merging it. See
+# docker-compose.override.yml.example and the self-hosting operations docs for the full pattern.
+x-runner-tmp-env: &runner-tmp-env
+ TMPDIR: /tmp/runner/tmp
+ TMP: /tmp/runner/tmp
+ TEMP: /tmp/runner/tmp
+
services:
# ── Core app (always runs) ─────────────────────────────────────────────────
gittensory:
@@ -620,6 +637,20 @@ services:
# containers on an 8-vCPU box left the app starved under load. See docker-compose.override.yml.example
# for a proven CPU-priority pattern (relative cpu_shares + a per-container cpus ceiling) before scaling
# this up, and ./scripts/docker-prune.sh for reclaiming the disk space CI builds accumulate.
+ # One-shot bootstrap for the runner's TMPDIR (below): guarantees /tmp/runner/tmp exists (and is
+ # world-writable, matching real /tmp's mode -- the runner image's runtime user isn't guaranteed to be
+ # root) on the runner-work volume BEFORE the runner container starts. A fresh named volume has nothing
+ # at that path otherwise, and pointing TMPDIR at a directory that doesn't exist yet would break every
+ # job step that shells out expecting $TMPDIR to be usable. `mkdir -p` is idempotent, so this safely
+ # no-ops on every subsequent `docker compose up`; it never touches the runner image's own entrypoint.
+ runner-tmp-init:
+ image: alpine:3.20
+ <<: *default-logging
+ profiles: ["runners"]
+ volumes:
+ - runner-work:/tmp/runner
+ command: ["sh", "-c", "mkdir -p /tmp/runner/tmp && chmod 1777 /tmp/runner/tmp"]
+
runner:
# Pin to a specific version tag — `latest` is mutable. Find a digest via:
# docker pull myoung34/github-runner:ubuntu-jammy && docker inspect --format='{{index .RepoDigests 0}}' myoung34/github-runner:ubuntu-jammy
@@ -627,7 +658,11 @@ services:
restart: unless-stopped
<<: *default-logging
profiles: ["runners"]
+ depends_on:
+ runner-tmp-init:
+ condition: service_completed_successfully
environment:
+ <<: *runner-tmp-env
RUNNER_SCOPE: ${RUNNER_SCOPE:-repo}
REPO_URL: ${RUNNER_REPO_URL:-}
RUNNER_TOKEN: ${RUNNER_TOKEN:-}
diff --git a/test/unit/docs-selfhost-operations-runner-tmpdir.test.ts b/test/unit/docs-selfhost-operations-runner-tmpdir.test.ts
new file mode 100644
index 0000000000..e17f5270f6
--- /dev/null
+++ b/test/unit/docs-selfhost-operations-runner-tmpdir.test.ts
@@ -0,0 +1,74 @@
+import { readFileSync } from "node:fs";
+import { parseDocument } from "yaml";
+import { describe, expect, it } from "vitest";
+
+// Drift guard (#selfhost-runner-tmp): the self-hosting operations doc's multi-runner override snippet
+// must stay in sync with docker-compose.yml's real x-runner-tmp-env anchor + runner-tmp-init pattern --
+// mirrors the same source-of-truth-diff approach as docs-selfhost-troubleshooting-metric-names.test.ts.
+// If the real compose pattern ever changes without the docs snippet changing too, this fails instead of
+// operators copy-pasting a stale/incorrect multi-runner example.
+
+const DOC_PATH = "apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx";
+
+function readYamlWithMerge(path: string): Record