diff --git a/.dockerignore b/.dockerignore
index a30310ff12..d6e24e57d8 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -12,6 +12,12 @@ dist-ssr
coverage
.git
.claude
+# The self-host bundle's only entry point is src/server.ts (scripts/build-selfhost.mjs) and `npm ci`
+# only ever sees the root package*.json (copied before the rest of the tree) — the gittensory-ui
+# workspace app and the test suite are never read during the image build, so keep both out of the
+# build context entirely (measured: ~11MB of this repo's ~22MB tracked-file footprint).
+apps
+test
# Runtime-only private review config and subscription CLI auth must never enter image layers.
gittensory-config
**/gittensory-config
diff --git a/Dockerfile b/Dockerfile
index 164b72daee..7f55c11199 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -45,8 +45,12 @@ RUN mkdir -p /home/node/.npm-global /home/node/.npm \
&& ln -s /data/codex /home/node/.codex \
&& chown -h node:node /home/node/.codex \
&& chown -R node:node /home/node/.npm-global /home/node/.npm
+# `npm install -g` populates ~/.npm/_cacache (the download cache) as a side effect, but nothing at
+# runtime ever reads it -- left alone it becomes ~180MB of dead weight baked permanently into this
+# layer (measured: node_modules for both CLIs together is ~465MB, the npm cache adds another ~180MB
+# on top for zero runtime benefit). `npm cache clean --force` after the install trims that for free.
USER node
-RUN if [ "$INSTALL_AI_CLIS" = "true" ]; then npm install -g --foreground-scripts @anthropic-ai/claude-code@2.1.187 @openai/codex@0.142.0; fi
+RUN if [ "$INSTALL_AI_CLIS" = "true" ]; then npm install -g --foreground-scripts @anthropic-ai/claude-code@2.1.187 @openai/codex@0.142.0 && npm cache clean --force; fi
USER root
# Optional: enable visual review via an external Chrome sidecar (e.g. `browserless/chrome:latest`).
# Build with `--build-arg INSTALL_VISUAL_REVIEW=true` then set BROWSER_WS_ENDPOINT=
+ Measured rows below come from a real production instance running the full
+ profile set (qdrant + redis + observability +{" "}
+ backup + postgres + ollama) at steady state —
+ docker stats and docker system df snapshots, not a lab benchmark.
+ Estimated rows are reasoned from that same baseline plus each
+ service's declared deploy.resources.limits and image size in{" "}
+ docker-compose.yml — they have not been measured directly and could be off,
+ especially for CPU under real load. Treat estimates as a starting point for capacity
+ planning, not a guarantee.
+
| Profile | +CPU (steady state) | +Memory (steady state) | +Basis | +
|---|---|---|---|
+ Minimal — app + redis only (no profile flags)
+ |
+ ~3% of one core | +~400–600MiB | ++ Estimated: app + redis measured in isolation from the full-profile snapshot (app + 2.6% CPU / 365MiB; redis is idle-light and its 512MiB limit is never approached in + the full-profile run either). + | +
+ + --profile postgres
+ |
+ + +14% of one core (highest single-service CPU consumer) + | ++~200MiB | ++ Measured: 14.24% CPU / 196MiB of its 2GiB limit — comfortable headroom on memory, + but the largest CPU line item in the whole stack. + | +
+ + --profile qdrant
+ |
+ Low single-digit % | ++ Well under its 2GiB limit + | ++ Measured (part of the full-profile snapshot's "everything else" low-CPU, under-limit + group). Grows with RAG corpus size — expect this to climb on installs with many + indexed repos. + | +
+ + --profile observability
+ |
+ + Low single-digit % per service, except Grafana/Tempo below + | ++ Grafana ~305MiB (60% of 512MiB); Tempo ~209MiB (20% of 1GiB); Prometheus/Loki/ + Alertmanager/Promtail/otel-collector each well under their limits + | ++ Measured. Grafana is the closest any service comes to its ceiling in production — + worth watching if you add many custom dashboards or panels, but not currently a + problem (40% headroom remains). + | +
+ + --profile ollama
+ |
+ + Near-zero idle; spikes hard during inference + | ++ Model-dependent, up to its 8GiB limit + | +
+ Estimated. Not part of the live production profile mix (that instance uses{" "}
+ AI_PROVIDER=codex, not Ollama) — the 8GiB default limit is sized for a
+ single loaded 7–8B quantized model per the compose comment, not measured against a
+ running model. Idle Ollama with no model pulled is cheap; a loaded model can
+ legitimately approach the limit, which is why it has the largest default ceiling in
+ the file.
+ |
+
+ + --profile backup
+ |
+ + Near-zero except during runs + | ++ Low, bursts during dump/restore + | +
+ Measured as part of the full-profile snapshot (no dedicated resource limit is set
+ for backup/backup-exporter — both are short-lived or
+ idle-polling processes, not sustained consumers).
+ |
+
+ + --profile runners
+ |
+ + Unbounded by default — can starve the app under CI load + | +Unbounded by default | +
+ Estimated, and explicitly a known risk, not a guess about typical usage: the{" "}
+ runner service ships with no CPU/memory limit at all. Production
+ experience already documented in docker-compose.override.yml.example{" "}
+ found 3 uncapped runner containers starving the app for CPU on an 8-vCPU box under
+ real CI load — see that file for the cpu_shares/cpus{" "}
+ mitigation before co-locating runners with the review stack.
+ |
+
+ Full profile set (qdrant + redis +{" "}
+ observability + backup + postgres +{" "}
+ ollama, no active inference, no runners)
+ |
+ + Postgres (~14%) dominates; everything else low single-digit % + | ++ No service near its limit except Grafana (~60%) + | ++ Measured, in full, on a real production instance. + | +
+ Measured on the same production instance: 48GB of 151GB used on the host root volume (32%)
+ at steady state. docker system df breakdown:
+
+ The reclaimable image and build-cache space here is{" "}
+ expected steady state, not a leak — this instance runs{" "}
+ scripts/deploy-selfhost-prebuilt.sh, which rebuilds the image from the current
+ git checkout on every deploy and intentionally keeps prior layers around in the build cache
+ for faster rebuilds. The gittensory-docker-safe-prune systemd timer (below)
+ already runs daily against this exact instance and reclaims it on a schedule, so this is not
+ a number to chase down manually.
+
+ Every deploy.resources.limits.memory in docker-compose.yml is
+ operator-overridable via .env (see the *_MEM_LIMIT variables in{" "}
+ .env.example). Against the measured full-profile data above, none of the
+ current defaults look miscalibrated enough to change: nothing sits consistently near its
+ limit in a way that risks an OOM kill under normal load (Grafana's ~60% is the closest
+ and still has real headroom), and nothing is so oversized relative to plausible usage that
+ it should be lowered — including Ollama's comparatively large 8GiB ceiling, which is
+ sized for holding one quantized model in memory, not idle overhead. The one real gap is{" "}
+ --profile runners, which ships with no limit at all; that is a known,
+ documented tradeoff (see the table above and{" "}
+ docker-compose.override.yml.example) rather than an oversight, since the right
+ ceiling depends entirely on the host's core count and how many runner replicas you run.
+
Every service in docker-compose.yml caps its own container logs (10MB × 3