dorfteich/deploy/compose/docker-compose.yml
Claude Fable 5 8dbff86537
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
Add backup sidecar: nightly dump, volume archive, prune, status, restore (#83)
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the
uploads/plugins volumes as a consistent restore set on a new backups volume,
retention prune that never removes the newest complete set, atomic
status.json for the readiness/admin consumers (#85/#86), and a failure mail
sent directly via nodemailer (the api may be the broken part) with de/en
texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand
path; deploy/backup/restore.sh automates the documented restore runbook.
The pure secret-store helpers moved to @dorfteich/shared so the sidecar
resolves the wizard-written SMTP relay exactly like the api.

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

232 lines
8.0 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
# 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
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
networks:
frontend:
internal:
volumes:
db-data:
uploads:
plugins:
secrets:
backups: