dorfteich/deploy/compose/docker-compose.yml
Claude Fable 5 4b55fb92ac
All checks were successful
CD / Build and push images (push) Successful in 1m7s
CD / Deploy to Test (push) Successful in 10s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 3m13s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 5m18s
CI / Import/export fidelity gate (push) Successful in 46s
Write the self-hosting guide and add the optional caddy TLS profile (#88)
docs/self-hosting/README.md is the complete operator contract: install
from the two reference files, first-run wizard walkthrough, update
procedure with the one-release downgrade window, backup/restore with the
sidecar, readyz-based troubleshooting (incl. the classic proxy/WebSocket
and APP_BASE_URL/CSRF mistakes), and a build-from-source note; linked
from the repository README; English-only by documented decision. The
reference compose gains a `caddy` profile (new Caddyfile) that publishes
80/443 and terminates TLS via Let's Encrypt for $DOMAIN — localhost uses
Caddy's internal CA for smoke tests. deploy/self-hosting-verify.sh
scripts the clean-machine test: a fresh directory with only the
published files boots to the wizard answering over TLS, then removes
itself; verified green on the stage host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 21:21:10 +02:00

263 lines
9.1 KiB
YAML

# 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}
# 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
# 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
- 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}}
# 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 uploads/plugins is for the restore path only; the
# nightly run just reads them into the archive.
- uploads:/data/uploads
- plugins:/data/plugins
- secrets:/data/secrets:ro
- backups:/backups
depends_on:
db:
condition: service_healthy
<<: *logging
db:
image: postgres:17.5-alpine
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: pandoc/core:3.6
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: gotenberg/gotenberg:8
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: caddy:2.10-alpine
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:
secrets:
backups:
# Only used by the optional `caddy` profile (certificates + state).
caddy-data:
caddy-config: