All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s
BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains where backups may go, enforced twice: the api rejects settings writes and connection tests towards non-allowlisted hosts with admin-visible error codes and resolves a non-allowlisted configured target to null, and the sidecar enforces the same policy at the point of egress for the WebDAV upload and the rsync mirror alike (shared policy helpers in packages/shared/src/backup-target-policy.ts). BREAKING: the empty default disables every remote target - backups stay local only, the VS-NfD reference configuration (ADR 0026). Existing deployments with a remote target must list its host or uploads and mirror stop. The admin UI distinguishes unavailable-by-policy from unconfigured (i18n de+en) and shows the permitted hosts. Refs #192 (ADR 0026) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
278 lines
10 KiB
YAML
278 lines
10 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}
|
|
# 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:-}
|
|
# 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}}
|
|
# 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 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:
|