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