Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ docker compose --profile postgres --profile observability --profile backup up -d
<code>Dead jobs stay at zero</code> routine check below is watching for.
</p>

<h2>Docker resource hygiene</h2>
<p>
Every service in <code>docker-compose.yml</code> caps its own container logs (10MB × 3
rotated files) out of the box, so log growth alone won&apos;t fill your disk. Unused Docker
images and build cache are a separate, larger disk-growth vector on a host that rebuilds or
pulls images repeatedly over months — Docker does not reclaim either automatically.
</p>
<p>
Install the provided host-level timer to reclaim both on a schedule (anything unused for
less than 7 days is left alone, so a recent deploy is never at risk):
</p>
<CodeBlock
lang="bash"
code={`sudo cp systemd/gittensory-docker-prune.service.example /etc/systemd/system/gittensory-docker-prune.service
sudo cp systemd/gittensory-docker-prune.timer.example /etc/systemd/system/gittensory-docker-prune.timer
sudo $EDITOR /etc/systemd/system/gittensory-docker-prune.service # set WorkingDirectory / ExecStart to your path
sudo systemctl daemon-reload
sudo systemctl enable --now gittensory-docker-prune.timer`}
/>
<p>
Run it manually at any time with <code>docker system df</code> before and after to see what
it reclaimed: <code>sh scripts/selfhost-docker-prune.sh</code>.
</p>

<h2>Sentry tracing</h2>
<p>
Leave <code>SENTRY_TRACES_SAMPLE_RATE</code> unset or blank to disable trace export, or set
Expand Down
35 changes: 35 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@
# docker compose --profile observability up -d # metrics + logs + dashboards
# docker compose --profile tailscale --profile runners up -d # tailnet + CI runners

# Bounded container logging (#audit-rate-headroom): every service below defaults to Docker's
# json-file driver, which has NO size cap on its own -- a long-running 24/7 stack can fill the
# host disk purely from log growth (the app, Postgres, Redis, and the CI runners are the biggest
# producers). This anchor caps each service to 3 rotated files of 10MB (30MB/service ceiling,
# ~600MB worst case across every service in this file) and is merged into every service via
# `<<: *default-logging`. Override per-service if you need more retained log history.
x-logging: &default-logging
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

services:
# ── Core app (always runs) ─────────────────────────────────────────────────
gittensory:
Expand All @@ -31,6 +44,7 @@ services:
INSTALL_AI_CLIS: "${INSTALL_AI_CLIS:-true}"
INSTALL_VISUAL_REVIEW: "${INSTALL_VISUAL_REVIEW:-false}"
restart: unless-stopped
<<: *default-logging
ports:
# Remove this when using the caddy profile — Caddy becomes the public listener.
- "${PORT:-8787}:8787"
Expand Down Expand Up @@ -121,6 +135,7 @@ services:
redis:
image: redis:7-alpine
restart: unless-stopped
<<: *default-logging
command:
- redis-server
- --maxmemory
Expand All @@ -140,6 +155,7 @@ services:
postgres:
image: pgvector/pgvector:pg16
restart: unless-stopped
<<: *default-logging
profiles: ["postgres", "pgbouncer"]
environment:
POSTGRES_USER: gittensory
Expand All @@ -158,6 +174,7 @@ services:
pgbouncer:
image: edoburu/pgbouncer:v1.25.2-p0
restart: unless-stopped
<<: *default-logging
profiles: ["pgbouncer"]
depends_on:
postgres:
Expand Down Expand Up @@ -188,6 +205,7 @@ services:
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:v0.20.0
restart: unless-stopped
<<: *default-logging
profiles: ["postgres", "pgbouncer"]
depends_on:
postgres:
Expand All @@ -206,6 +224,7 @@ services:
qdrant:
image: qdrant/qdrant:v1.18.2
restart: unless-stopped
<<: *default-logging
profiles: ["qdrant"]
# Ports are bound to LOOPBACK (127.0.0.1), not 0.0.0.0: the app reaches Qdrant over the internal
# docker network (QDRANT_URL=http://qdrant:6333), so the host mapping exists only for the local
Expand Down Expand Up @@ -240,6 +259,7 @@ services:
ollama:
image: ollama/ollama:0.30.10
restart: unless-stopped
<<: *default-logging
profiles: ["ollama"]
volumes:
- ollama-models:/root/.ollama
Expand All @@ -258,6 +278,7 @@ services:
litestream:
image: litestream/litestream:0.5.12
restart: unless-stopped
<<: *default-logging
profiles: ["litestream"]
command: replicate
depends_on:
Expand All @@ -278,6 +299,7 @@ services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
<<: *default-logging
profiles: ["caddy"]
ports:
- "80:80"
Expand All @@ -299,6 +321,7 @@ services:
prometheus:
image: prom/prometheus:v3.12.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
Expand All @@ -314,6 +337,7 @@ services:
alertmanager:
image: prom/alertmanager:v0.33.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on: [prometheus]
expose:
Expand All @@ -328,6 +352,7 @@ services:
grafana:
image: grafana/grafana:13.1.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on:
prometheus:
Expand Down Expand Up @@ -365,6 +390,7 @@ services:
reporting-exporter:
image: alpine:3.20
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on:
gittensory:
Expand Down Expand Up @@ -414,6 +440,7 @@ services:
loki:
image: grafana/loki:3.7.3
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
command: ["-config.file=/etc/loki/loki-config.yml"]
volumes:
Expand All @@ -437,6 +464,7 @@ services:
docker-proxy:
image: tecnativa/docker-socket-proxy:0.3.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
environment:
CONTAINERS: "1" # GET /containers/* (list, inspect, logs)
Expand All @@ -449,6 +477,7 @@ services:
promtail:
image: grafana/promtail:3.6.11
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
command:
[
Expand All @@ -475,6 +504,7 @@ services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.155.0
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
depends_on:
tempo:
Expand All @@ -489,6 +519,7 @@ services:
tempo:
image: grafana/tempo:2.6.1
restart: unless-stopped
<<: *default-logging
profiles: ["observability"]
command: ["-config.file=/etc/tempo/tempo.yaml"]
volumes:
Expand All @@ -512,6 +543,7 @@ services:
# --format='{{index .RepoDigests 0}}' ghcr.io/tailscale/tailscale:stable
image: ghcr.io/tailscale/tailscale:stable
restart: unless-stopped
<<: *default-logging
profiles: ["tailscale"]
hostname: gittensory
cap_add:
Expand All @@ -538,6 +570,7 @@ services:
# docker pull myoung34/github-runner:ubuntu-jammy && docker inspect --format='{{index .RepoDigests 0}}' myoung34/github-runner:ubuntu-jammy
image: myoung34/github-runner:ubuntu-jammy
restart: unless-stopped
<<: *default-logging
profiles: ["runners"]
environment:
RUNNER_SCOPE: ${RUNNER_SCOPE:-repo}
Expand All @@ -562,6 +595,7 @@ services:
backup:
image: alpine:3.20
restart: unless-stopped
<<: *default-logging
profiles: ["backup"]
environment:
DATABASE_PATH: /data/gittensory.sqlite
Expand Down Expand Up @@ -601,6 +635,7 @@ services:
backup-exporter:
image: alpine:3.20
restart: unless-stopped
<<: *default-logging
profiles: ["backup"]
volumes:
- gittensory-backups:/backups:ro
Expand Down
30 changes: 30 additions & 0 deletions scripts/selfhost-docker-prune.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/sh
# Automated Docker resource hygiene for a 24/7 self-hosted gittensory stack (#audit-rate-headroom). Runs on
# the HOST (via the systemd timer in systemd/gittensory-docker-prune.{service,timer}.example), not as a
# compose service: reclaiming unused images and build cache needs real Docker daemon access, which this
# repo deliberately does not grant to any container (see docker-compose.yml's docker-proxy and runner
# service comments on why raw docker.sock exposure into a container is avoided).
#
# Age-filtered so nothing built/pulled recently is touched -- a rollback within the retention window still
# has its image available. `docker image prune -a` and `docker builder prune` only ever remove resources
# Docker itself already reports as unused (a running container's own image, or an active build-cache entry
# a build is currently using, are never candidates) -- this script does not change that safety property, it
# only adds the age floor on top of it.
set -eu

RETAIN_HOURS=${GITTENSORY_DOCKER_PRUNE_RETAIN_HOURS:-168} # 7 days

echo "[docker-prune] $(date -u +%FT%TZ) starting (retain: ${RETAIN_HOURS}h)"
echo "[docker-prune] before:"
docker system df

echo "[docker-prune] pruning unused images older than ${RETAIN_HOURS}h..."
docker image prune -af --filter "until=${RETAIN_HOURS}h"

echo "[docker-prune] pruning build cache older than ${RETAIN_HOURS}h..."
docker builder prune -af --filter "until=${RETAIN_HOURS}h"

echo "[docker-prune] after:"
docker system df

echo "[docker-prune] $(date -u +%FT%TZ) done"
27 changes: 27 additions & 0 deletions systemd/gittensory-docker-prune.service.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Runs scripts/selfhost-docker-prune.sh on a schedule (paired with gittensory-docker-prune.timer.example) to
# reclaim unused Docker images and build cache before disk fills up on a long-running self-host deployment.
#
# Install (adjust the path below to wherever you cloned/deployed gittensory):
# sudo cp systemd/gittensory-docker-prune.service.example /etc/systemd/system/gittensory-docker-prune.service
# sudo cp systemd/gittensory-docker-prune.timer.example /etc/systemd/system/gittensory-docker-prune.timer
# sudo $EDITOR /etc/systemd/system/gittensory-docker-prune.service # fix WorkingDirectory / ExecStart path
# sudo systemctl daemon-reload
# sudo systemctl enable --now gittensory-docker-prune.timer
#
# This is a HOST-level unit, not a Docker Compose service: reclaiming images/build-cache needs real Docker
# daemon access, which no container in docker-compose.yml is granted (see that file's docker-proxy and
# runner service comments for why raw /var/run/docker.sock exposure into a container is avoided).

[Unit]
Description=Gittensory self-host Docker resource hygiene (image + build-cache prune)
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
# REQUIRED: point this at wherever you cloned gittensory.
WorkingDirectory=/opt/gittensory
ExecStart=/bin/sh /opt/gittensory/scripts/selfhost-docker-prune.sh
# Optional: override the default 7-day (168h) safety window before an unused image/build-cache entry is
# eligible for removal.
# Environment=GITTENSORY_DOCKER_PRUNE_RETAIN_HOURS=168
13 changes: 13 additions & 0 deletions systemd/gittensory-docker-prune.timer.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Pairs with gittensory-docker-prune.service.example. See that file for install instructions.

[Unit]
Description=Run gittensory-docker-prune.service daily

[Timer]
OnCalendar=daily
Persistent=true
# Spread the run over a window instead of firing at exactly midnight on every host.
RandomizedDelaySec=30m

[Install]
WantedBy=timers.target
46 changes: 46 additions & 0 deletions test/unit/selfhost-compose-logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { parseDocument } from "yaml";
import { describe, expect, it } from "vitest";

function readYamlWithMerge(path: string): Record<string, unknown> {
const doc = parseDocument(readFileSync(path, "utf8"), { merge: true });
const value = doc.toJS();
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${path} must be a YAML object`);
}
return value as Record<string, unknown>;
}

// Pure structural checks only (no `docker` CLI invocation): the self-hosted runner container this actually
// runs on does not have Docker-in-Docker access, so a test that shells out to `docker compose config`
// would be unreliable/environment-dependent here (same constraint as the other selfhost-compose-*.test.ts
// files). `{ merge: true }` makes the `yaml` package resolve `<<: *default-logging` the same way Docker
// Compose's own YAML 1.1 merge-key support does -- verified once by hand against `docker compose config`
// with every profile active before this test was written.
describe("docker-compose.yml — bounded container logging (#audit-rate-headroom)", () => {
it("caps every service's logs via the shared x-logging anchor, so none defaults to Docker's unbounded json-file driver", () => {
const compose = readYamlWithMerge("docker-compose.yml");
const services = (compose.services as Record<string, Record<string, unknown>>) ?? {};
const serviceNames = Object.keys(services);

// Guard against a future service quietly skipping the anchor (e.g. a copy-pasted block that dropped the
// merge key) -- every single service must resolve a bounded logging config, not just a sample of them.
expect(serviceNames.length).toBeGreaterThan(15);
for (const name of serviceNames) {
const logging = services[name]?.logging as { driver?: string; options?: Record<string, string> } | undefined;
expect(logging, `service "${name}" is missing a logging config`).toBeDefined();
expect(logging?.driver).toBe("json-file");
expect(logging?.options?.["max-size"]).toBe("10m");
expect(logging?.options?.["max-file"]).toBe("3");
}
});

it("defines the shared anchor once with a sane cap, so a future edit only needs to change one place", () => {
const compose = readYamlWithMerge("docker-compose.yml");
const shared = compose["x-logging"] as { logging?: { driver?: string; options?: Record<string, string> } };

expect(shared?.logging?.driver).toBe("json-file");
expect(shared?.logging?.options?.["max-size"]).toBe("10m");
expect(shared?.logging?.options?.["max-file"]).toBe("3");
});
});
Loading
Loading