dorfteich/deploy/compose/docker-compose.yml
Claude Fable 5 d2be1116bc
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m43s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Successful in 8m52s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 16s
CD / Deploy to Test (push) Successful in 17s
CD / Smoke tests against Test (push) Successful in 1m25s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m42s
CI / Import/export fidelity gate (push) Successful in 58s
Refresh the self-hosting guide for the first public release (#320)
- TAG guidance points at pinned release tags (e.g. v0.14.0) instead of
  the pre-release `test` tag; concrete curl commands fetch the three
  reference files.
- Backup wording (guide + .env.example) names all four data volumes in
  the restore set (uploads, plugins, custom fonts, branding).
- Updating section states the back-up-first step and links the update
  runbook.
- Pass the external-authentication variables (OIDC_*, AUTH_LOCAL_ENABLED,
  AUTH_PROXY_*) through the reference compose and document them in
  .env.example: they were documented in security.md but unreachable from
  .env. Empty values count as unset (app-config.service.ts), so the block
  is inert until configured.
- New guide section "External authentication (optional)"; neutral
  APP_BASE_URL example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aviRTgWCcAHUh1SBoxf6P
2026-08-03 12:34:30 +02:00

309 lines
12 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Production Compose stack — one file for every stage and for self-hosters.
# Configuration comes from .env (see .env.example); the host reverse proxy
# routes to the two published localhost ports (deployment.md §Compose).
#
# Networks: `frontend` is what the reverse proxy reaches (via published
# ports); `internal` connects api/collab to db and (later) the converter
# sidecars, which are never exposed.
name: ${COMPOSE_PROJECT_NAME:-dorfteich}
x-logging: &logging
logging:
driver: json-file
options:
max-size: '10m'
max-file: '5'
services:
web:
image: ${IMAGE_PREFIX:-dorfteich}-web:${TAG:-latest}
build:
context: ../..
dockerfile: apps/web/Dockerfile
args:
APP_VERSION: ${TAG:-latest}
restart: unless-stopped
ports:
- '127.0.0.1:${WEB_PORT:-8100}:8080'
networks: [frontend]
depends_on:
api:
condition: service_started
<<: *logging
api:
image: ${IMAGE_PREFIX:-dorfteich}-api:${TAG:-latest}
build:
context: ../..
dockerfile: apps/api/Dockerfile
args:
APP_VERSION: ${TAG:-latest}
restart: unless-stopped
environment:
NODE_ENV: production
PORT: '3000'
LOG_LEVEL: ${LOG_LEVEL:-info}
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich
# Signs the short-lived collaboration tokens; the collab service below
# verifies them, so both MUST carry the same value (issue #34).
COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:?set in .env}
# Public URL of this stage — e-mail links and the CSRF origin check
# depend on it matching what browsers actually use.
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:5173}
# Session bounds in hours (issue #190); empty = application defaults
# (absolute 168 h, idle 72 h). Hardened deployments set them lower.
SESSION_ABSOLUTE_HOURS: ${SESSION_ABSOLUTE_HOURS:-}
SESSION_IDLE_HOURS: ${SESSION_IDLE_HOURS:-}
# Must match the backup service's value — the api validates admin
# backup settings against the same allowlist (issue #192).
BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-}
# VS-NfD hardening-profile mode (issue #243, ADR 0027):
# off | marked | hidden | enforced. Empty = off — no marking anywhere.
VS_NFD_MODE: ${VS_NFD_MODE:-}
# External authentication (issues #214#216, ADR 0021) — deploy-level
# on purpose, out of a Site Admin's reach. All empty = local
# username/password login only. Reference: security.md §External
# authentication.
OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_SCOPES: ${OIDC_SCOPES:-}
OIDC_PROVIDER_LABEL: ${OIDC_PROVIDER_LABEL:-}
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-}
AUTH_PROXY_HEADER: ${AUTH_PROXY_HEADER:-}
AUTH_PROXY_TRUSTED_PEERS: ${AUTH_PROXY_TRUSTED_PEERS:-}
AUTH_PROXY_MAP: ${AUTH_PROXY_MAP:-}
AUTH_PROXY_MODE: ${AUTH_PROXY_MODE:-}
AUTH_PROXY_DN_ATTRIBUTE: ${AUTH_PROXY_DN_ATTRIBUTE:-}
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
# the relay to the secret store on the `secrets` volume (issue #80);
# values set here in the stage .env always win over the store.
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-}
SMTP_SECURE: ${SMTP_SECURE:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
# Env-backed secret store on the `secrets` volume mount below
# (security.md §Secrets, issue #80).
SECRETS_FILE: /data/secrets/secrets.env
# Optional first-run pre-seeding (issue #80): with all three
# SETUP_ADMIN_* values set, a fresh database skips the browser wizard.
SETUP_ADMIN_USERNAME: ${SETUP_ADMIN_USERNAME:-}
SETUP_ADMIN_EMAIL: ${SETUP_ADMIN_EMAIL:-}
SETUP_ADMIN_PASSWORD: ${SETUP_ADMIN_PASSWORD:-}
SETUP_ADMIN_DISPLAY_NAME: ${SETUP_ADMIN_DISPLAY_NAME:-}
SETUP_INSTANCE_NAME: ${SETUP_INSTANCE_NAME:-}
SETUP_DEFAULT_LOCALE: ${SETUP_DEFAULT_LOCALE:-}
SETUP_REGISTRATION_MODE: ${SETUP_REGISTRATION_MODE:-}
# Matches the `uploads` volume mount below (ADR 0011).
UPLOADS_DIR: /data/uploads
# Matches the `plugins` volume mount below (ADR 0008, issue #71). A Site
# Admin drops ZIPs into its `_dropzone/` subfolder; the watcher installs them.
PLUGINS_DIR: /data/plugins
# Operator-uploaded fonts (issue #303, ADR 0016 §#303). A sibling of
# uploads/plugins so all three travel in one restore set — NOT inside
# the image-baked font catalog, which a deploy would overwrite.
CUSTOM_FONTS_DIR: /data/fonts
# Instance and pond branding assets (issues #306/#307) — same reasoning
# as the fonts above: operator data, so its own volume in the restore set.
BRANDING_DIR: /data/branding
# Read-only view of the backup sidecar's volume — the api only consumes
# its status.json (readyz freshness #85, admin backup card #86).
BACKUPS_DIR: /data/backups
# Internal pandoc-server sidecar for import/export (ADR 0009, issue #62).
PANDOC_URL: http://pandoc:3030
# Internal Gotenberg sidecar for PDF export (ADR 0009, issue #67).
GOTENBERG_URL: http://gotenberg:3000
ports:
- '127.0.0.1:${API_PORT:-8101}:3000'
networks: [frontend, internal]
volumes:
- uploads:/data/uploads
- plugins:/data/plugins
- customfonts:/data/fonts
- branding:/data/branding
- secrets:/data/secrets
- backups:/data/backups:ro
depends_on:
db:
condition: service_healthy
pandoc:
condition: service_healthy
gotenberg:
condition: service_healthy
<<: *logging
collab:
image: ${IMAGE_PREFIX:-dorfteich}-collab:${TAG:-latest}
build:
context: ../..
dockerfile: apps/collab/Dockerfile
args:
APP_VERSION: ${TAG:-latest}
restart: unless-stopped
environment:
NODE_ENV: production
PORT: '3000'
LOG_LEVEL: ${LOG_LEVEL:-info}
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich
# Must match the api's value — this service verifies the tokens it signs.
COLLAB_TOKEN_SECRET: ${COLLAB_TOKEN_SECRET:?set in .env}
ports:
# The host reverse proxy routes /collab here with WebSocket upgrade
# (deployment.md, deploy/stages.md).
- '127.0.0.1:${COLLAB_PORT:-8102}:3000'
networks: [frontend, internal]
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3000/healthz || exit 1']
interval: 30s
timeout: 5s
retries: 3
depends_on:
db:
condition: service_healthy
<<: *logging
# Backup sidecar (ADR 0015, issue #83): nightly `pg_dump -Fc` + one tar of
# the uploads/plugins volumes as a consistent restore set on the `backups`
# volume, prune by retention, `status.json`, failure mail directly via SMTP
# (the api may be the broken part). Restore runs through
# deploy/backup/restore.sh, which drives this same image.
backup:
image: ${IMAGE_PREFIX:-dorfteich}-backup:${TAG:-latest}
build:
context: ../..
dockerfile: apps/backup/Dockerfile
args:
APP_VERSION: ${TAG:-latest}
restart: unless-stopped
environment:
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
DATABASE_URL: postgresql://dorfteich:${POSTGRES_PASSWORD:?set in .env}@db:5432/dorfteich
# Daily run time (HH:MM) in TZ; retention 30 d default, 7 d on Test/Int.
TZ: ${TZ:-}
BACKUP_TIME: ${BACKUP_TIME:-}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-}
# Failure-alert recipient; empty disables the mail (logged instead).
BACKUP_MAIL_TO: ${BACKUP_MAIL_TO:-}
BACKUP_MAIL_LOCALE: ${BACKUP_MAIL_LOCALE:-}
BACKUP_INSTANCE_LABEL: ${BACKUP_INSTANCE_LABEL:-${COMPOSE_PROJECT_NAME:-dorfteich}}
# Optional rsync mirror to a private host (issue #84); the SSH key
# lives on the secrets volume (see deploy/backup-basel.md).
BACKUP_MIRROR_TARGET: ${BACKUP_MIRROR_TARGET:-}
BACKUP_MIRROR_SSH_KEY: ${BACKUP_MIRROR_SSH_KEY:-}
BACKUP_MIRROR_SSH_PORT: ${BACKUP_MIRROR_SSH_PORT:-}
# Deploy-level allowlist of backup destination hosts (issue #192,
# ADR 0026). Empty disables ALL remote targets (WebDAV + mirror).
BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-}
# Same SMTP resolution as the api: explicit env wins, the wizard-written
# secret store fills the gaps (issue #80).
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-}
SMTP_SECURE: ${SMTP_SECURE:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-}
networks: [internal]
volumes:
# Write access to the data mounts is for the restore path only; the
# nightly run just reads them into the archive.
- uploads:/data/uploads
- plugins:/data/plugins
- customfonts:/data/fonts
- branding:/data/branding
- secrets:/data/secrets:ro
- backups:/backups
depends_on:
db:
condition: service_healthy
<<: *logging
db:
image: ${REGISTRY_PREFIX:-}postgres:17.5-alpine@sha256:6567bca8d7bc8c82c5922425a0baee57be8402df92bae5eacad5f01ae9544daa
restart: unless-stopped
environment:
POSTGRES_USER: dorfteich
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
POSTGRES_DB: dorfteich
networks: [internal]
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U dorfteich -d dorfteich']
interval: 10s
timeout: 3s
retries: 12
<<: *logging
# Import/export converter (ADR 0009, issue #62): pandoc in HTTP server mode
# on the internal network only — never exposed. Pinned image; the api reaches
# it at http://pandoc:3030. `wget` ships in the (busybox-based) image.
pandoc:
image: ${REGISTRY_PREFIX:-}pandoc/core:3.6@sha256:5b8a29d9b70d5d8ca766e5d1dcfc41916b23ab79276a80527be70516110f4c1e
command: ['server']
restart: unless-stopped
networks: [internal]
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1:3030/version || exit 1']
interval: 30s
timeout: 5s
retries: 3
<<: *logging
# PDF export renderer (ADR 0009, issue #67): Gotenberg wraps headless Chromium
# on the internal network only — never exposed. Pinned image; the api reaches
# it at http://gotenberg:3000 and posts export HTML to its Chromium route.
gotenberg:
image: ${REGISTRY_PREFIX:-}gotenberg/gotenberg:8@sha256:67097317623a503ba2a6a7e9ae8db6929a1f7e1bbd88077bacf2d325fbdab923
restart: unless-stopped
networks: [internal]
healthcheck:
test: ['CMD-SHELL', 'curl -sf http://127.0.0.1:3000/health || exit 1']
interval: 30s
timeout: 5s
retries: 3
<<: *logging
# Optional TLS ingress (issue #88): for self-hosters without their own
# reverse proxy. `docker compose --profile caddy up -d` publishes 80/443
# and terminates TLS via Let's Encrypt for $DOMAIN (set it in .env; the
# `localhost` default uses Caddy's internal CA — handy for smoke tests).
# Instances behind an existing host proxy simply never enable the profile.
caddy:
image: ${REGISTRY_PREFIX:-}caddy:2.10-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d
profiles: [caddy]
restart: unless-stopped
ports:
- '${CADDY_HTTP_PORT:-80}:80'
- '${CADDY_HTTPS_PORT:-443}:443'
environment:
DOMAIN: ${DOMAIN:-localhost}
networks: [frontend]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
web:
condition: service_started
<<: *logging
networks:
frontend:
internal:
volumes:
db-data:
uploads:
plugins:
customfonts:
branding:
secrets:
backups:
# Only used by the optional `caddy` profile (certificates + state).
caddy-data:
caddy-config: