Files
FireAdministration/docker-compose.yml
T
Florian WildeandClaude Opus 5 4e2daaafd4 Atemschutzueberwachung nach FwDV 7 ergaenzen
Phase 5: das sicherheitsrelevanteste Modul des Projekts. Ueber jeder Ansicht
steht dauerhaft, dass die analoge Atemschutzueberwachung fuehrend bleibt —
abschaltbar nur zusammen mit der Fundstelle einer Freigabe des Kommandanten.

Es gibt keinen Timer. In der Datenbank stehen Zeitstempel; wie lange ein
Trupp unter Atemschutz ist, rechnet jede Ansicht bei jedem Bild neu aus.
Damit ist die Anforderung "uebersteht Neuladen und Verbindungsabbruch" keine
Funktion, die gelingen muss, sondern eine Eigenschaft, die sich nicht
verlieren laesst. Server und Browser rechnen mit denselben Funktionen.

Der Rueckzugsdruck folgt dem doppelten Anmarschverbrauch: was der Weg hinein
gekostet hat, kostet der Weg zurueck noch einmal. Bis der Trupp das
Einsatzziel meldet, greift die Zwei-Drittel-Regel — sie liegt hoeher und ist
damit die vorsichtigere Annahme.

Das Protokoll ist doppelt gesichert: entzogene Rechte und ein Trigger auf
druckmessung und atemschutz_ereignis. Ein Fehler wird durch einen neuen
Eintrag richtiggestellt, nicht durch Aendern.

Verteilt wird ueber Server-Sent Events statt Socket.IO — es geht nur in eine
Richtung, und so bleibt das Deployment bei "next start".

Behoben: ein abgerissener Ereignisstrom meldete sich nicht. Die Anzeige haette
weiter "Verbunden" behauptet, waehrend der Draht tot war. Jetzt gilt Stille
von acht Sekunden als Abriss. Dazu zwei Altlasten: der Navigationseintrag
"Protokoll" fuehrte ins Leere, und die Startseite stand noch auf Phase 1.

68 neue Tests (51 Unit, 17 gegen eine echte Datenbank, 8 E2E je Browser).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 23:19:05 +02:00

360 lines
13 KiB
YAML

# =============================================================================
# FLORIAN — Produktionsnaher Stack.
# Für die Entwicklung wird docker-compose.override.yml automatisch daraufgelegt;
# die veröffentlicht die Dienste direkt auf localhost und braucht kein Traefik.
# Produktion starten: docker compose -f docker-compose.yml up -d
# Entwicklung starten: docker compose up -d
# =============================================================================
name: florian
x-app-env: &app-env
NODE_ENV: ${NODE_ENV:-production}
TZ: ${TZ:-Europe/Berlin}
APP_URL: ${APP_URL:-http://localhost:3000}
DATABASE_URL: ${DATABASE_URL}
DIRECT_DATABASE_URL: ${DIRECT_DATABASE_URL}
REDIS_URL: ${REDIS_URL}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5}
S3_ENDPOINT: ${S3_ENDPOINT:-minio}
S3_PORT: ${S3_PORT:-9000}
S3_USE_SSL: ${S3_USE_SSL:-false}
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-eu-central-1}
S3_BUCKET: ${S3_BUCKET:-florian}
S3_PUBLIC_URL: ${S3_PUBLIC_URL}
S3_URL_TTL: ${S3_URL_TTL:-900}
GOTENBERG_URL: ${GOTENBERG_URL:-http://gotenberg:3000}
GOTENBERG_TIMEOUT: ${GOTENBERG_TIMEOUT:-30}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
AUTH_SESSION_MAX_AGE: ${AUTH_SESSION_MAX_AGE:-28800}
AUTH_OIDC_ENABLED: ${AUTH_OIDC_ENABLED:-false}
AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER:-}
AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID:-}
AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET:-}
AUTH_OIDC_NAME: ${AUTH_OIDC_NAME:-authentik}
AUTH_OIDC_GROUP_CLAIM: ${AUTH_OIDC_GROUP_CLAIM:-groups}
AUTH_OIDC_GROUP_MAPPING: ${AUTH_OIDC_GROUP_MAPPING:-}
AUTH_OIDC_AUTO_PROVISION: ${AUTH_OIDC_AUTO_PROVISION:-true}
AUTH_OIDC_STANDARD_ORGANISATION: ${AUTH_OIDC_STANDARD_ORGANISATION:-}
AUTH_LOCAL_ENABLED: ${AUTH_LOCAL_ENABLED:-true}
AUTH_BCRYPT_ROUNDS: ${AUTH_BCRYPT_ROUNDS:-12}
KIOSK_ENABLED: ${KIOSK_ENABLED:-true}
KIOSK_DEVICE_SECRET: ${KIOSK_DEVICE_SECRET}
KIOSK_PIN_LAENGE: ${KIOSK_PIN_LAENGE:-6}
KIOSK_MAX_FEHLVERSUCHE: ${KIOSK_MAX_FEHLVERSUCHE:-5}
KIOSK_SPERRDAUER: ${KIOSK_SPERRDAUER:-300}
KIOSK_IDLE_TIMEOUT: ${KIOSK_IDLE_TIMEOUT:-120}
ATEMSCHUTZ_HINWEIS: ${ATEMSCHUTZ_HINWEIS:-true}
ATEMSCHUTZ_FREIGABE: ${ATEMSCHUTZ_FREIGABE:-}
ATEMSCHUTZ_ABFRAGE_INTERVALL: ${ATEMSCHUTZ_ABFRAGE_INTERVALL:-600}
ATEMSCHUTZ_WARNDRUCK: ${ATEMSCHUTZ_WARNDRUCK:-55}
ATEMSCHUTZ_ZUSCHLAG: ${ATEMSCHUTZ_ZUSCHLAG:-10}
WETTER_ENABLED: ${WETTER_ENABLED:-false}
DWD_WARNCELL: ${DWD_WARNCELL:-}
DWD_WARNUNGEN_URL: ${DWD_WARNUNGEN_URL:-https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json}
WETTER_TIMEOUT: ${WETTER_TIMEOUT:-5}
WETTER_ZWISCHENSPEICHER: ${WETTER_ZWISCHENSPEICHER:-300}
MAIL_ENABLED: ${MAIL_ENABLED:-false}
MAIL_HOST: ${MAIL_HOST:-}
MAIL_PORT: ${MAIL_PORT:-587}
MAIL_SECURE: ${MAIL_SECURE:-false}
MAIL_USER: ${MAIL_USER:-}
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
MAIL_FROM: ${MAIL_FROM:-}
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
LLM_ENABLED: ${LLM_ENABLED:-false}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODELL: ${LLM_MODELL:-}
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
NOMINATIM_URL: ${NOMINATIM_URL:-}
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
GEOCODING_LAENDER: ${GEOCODING_LAENDER:-de,at,ch}
PORTAL_ENABLED: ${PORTAL_ENABLED:-true}
PORTAL_GUELTIGKEIT_STUNDEN: ${PORTAL_GUELTIGKEIT_STUNDEN:-24}
PORTAL_MAX_ZUGRIFFE: ${PORTAL_MAX_ZUGRIFFE:-120}
WARTUNG_NACHTLAUF: ${WARTUNG_NACHTLAUF:-15 3 * * *}
WARTUNG_ERINNERUNG_LAUF: ${WARTUNG_ERINNERUNG_LAUF:-45 3 * * *}
WARTUNG_PLANUNGSHORIZONT_TAGE: ${WARTUNG_PLANUNGSHORIZONT_TAGE:-180}
AUFBEWAHRUNG_JAHRE: ${AUFBEWAHRUNG_JAHRE:-10}
AUDIT_AUFBEWAHRUNG_TAGE: ${AUDIT_AUFBEWAHRUNG_TAGE:-3650}
PERSON_ANONYMISIERUNG_TAGE: ${PERSON_ANONYMISIERUNG_TAGE:-365}
NEXT_TELEMETRY_DISABLED: "1"
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
# ---------------------------------------------------------------------------
# Reverse Proxy. Terminiert TLS, verteilt auf App und MinIO.
# ---------------------------------------------------------------------------
traefik:
image: traefik:v3.3
container_name: florian-traefik
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --providers.docker.network=florian-edge
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entryPoint.to=websecure
- --entrypoints.web.http.redirections.entryPoint.scheme=https
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.email=${ACME_EMAIL}
- --certificatesresolvers.le.acme.storage=/acme/acme.json
- --certificatesresolvers.le.acme.tlschallenge=true
# Kein anonymes Nutzungs-Reporting.
- --global.sendAnonymousUsage=false
- --global.checkNewVersion=false
- --log.level=INFO
- --accesslog=true
ports:
- "80:80"
- "443:443"
volumes:
# Nur lesend — Traefik braucht die Socket-Verbindung zur Diensterkennung.
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-acme:/acme
networks:
- edge
logging: *logging
# ---------------------------------------------------------------------------
# PostgreSQL 17 mit PostGIS. Einziger Ort für relationale Daten.
# ---------------------------------------------------------------------------
postgres:
image: postgis/postgis:17-3.5
container_name: florian-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-florian}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-florian}
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD}
# Deutsche Sortierung ueber ICU — das Debian-Basisimage hat keine
# de_DE-Locale erzeugt, ICU liefert sie unabhaengig vom Betriebssystem.
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8"
TZ: ${TZ:-Europe/Berlin}
volumes:
- postgres-data:/var/lib/postgresql/data
# Legt beim ERSTEN Start den RLS-pflichtigen Anwendungsbenutzer an.
- ./docker/postgres:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-florian} -d ${POSTGRES_DB:-florian}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# Redis. Warteschlangen (BullMQ) und Rate-Limiting. Keine Nutzdaten.
# ---------------------------------------------------------------------------
redis:
image: redis:7-alpine
container_name: florian-redis
restart: unless-stopped
command:
- redis-server
- --requirepass
- ${REDIS_PASSWORD}
- --appendonly
- "yes"
- --maxmemory-policy
- noeviction
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# MinIO. Ablage ALLER Dateien — Fotos, Prüfprotokolle, Rechnungen, Exporte.
# In Produktion auf ein festes RELEASE-Tag pinnen.
# ---------------------------------------------------------------------------
minio:
image: minio/minio:latest
container_name: florian-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_BROWSER_REDIRECT_URL: https://${MINIO_CONSOLE_DOMAIN}
MINIO_SERVER_URL: https://${MINIO_DOMAIN}
MINIO_UPDATE: "off"
TZ: ${TZ:-Europe/Berlin}
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- intern
- edge
labels:
- traefik.enable=true
# S3-API
- traefik.http.routers.minio-api.rule=Host(`${MINIO_DOMAIN}`)
- traefik.http.routers.minio-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.minio-api.tls.certresolver=le
- traefik.http.routers.minio-api.service=minio-api
- traefik.http.services.minio-api.loadbalancer.server.port=9000
# Weboberfläche
- traefik.http.routers.minio-console.rule=Host(`${MINIO_CONSOLE_DOMAIN}`)
- traefik.http.routers.minio-console.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.minio-console.tls.certresolver=le
- traefik.http.routers.minio-console.service=minio-console
- traefik.http.services.minio-console.loadbalancer.server.port=9001
logging: *logging
# ---------------------------------------------------------------------------
# Einmaliger Job: legt den Bucket an und setzt ihn auf privat.
# ---------------------------------------------------------------------------
minio-init:
image: minio/mc:latest
container_name: florian-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
mc alias set florian http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
mc mb --ignore-existing florian/"$${S3_BUCKET}" &&
mc anonymous set none florian/"$${S3_BUCKET}" &&
mc version enable florian/"$${S3_BUCKET}" &&
echo "Bucket $${S3_BUCKET} bereit."
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${S3_BUCKET:-florian}
networks:
- intern
restart: "no"
logging: *logging
# ---------------------------------------------------------------------------
# Gotenberg. Rendert HTML zu PDF (Prüfprotokolle, Berichte).
# ---------------------------------------------------------------------------
gotenberg:
image: gotenberg/gotenberg:8
container_name: florian-gotenberg
restart: unless-stopped
command:
- gotenberg
- --chromium-disable-javascript=false
- --chromium-allow-list=file:///tmp/.*
- --api-timeout=${GOTENBERG_TIMEOUT:-30}s
environment:
TZ: ${TZ:-Europe/Berlin}
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# Die Anwendung selbst (Next.js, Standalone-Build).
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
# ---------------------------------------------------------------------------
app:
build:
context: .
dockerfile: docker/Dockerfile
target: runner
image: florian-app:${IMAGE_TAG:-latest}
container_name: florian-app
restart: unless-stopped
environment:
<<: *app-env
PORT: "3000"
HOSTNAME: "0.0.0.0"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
networks:
- intern
- edge
labels:
- traefik.enable=true
- traefik.http.routers.app.rule=Host(`${APP_DOMAIN}`)
- traefik.http.routers.app.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}
- traefik.http.routers.app.tls.certresolver=le
- traefik.http.services.app.loadbalancer.server.port=3000
logging: *logging
# ---------------------------------------------------------------------------
# Hintergrundjobs: Fristenberechnung, Erinnerungen, PDF-Erzeugung, Exporte.
# Gleiches Image wie die App, anderer Einstiegspunkt.
# ---------------------------------------------------------------------------
worker:
build:
context: .
dockerfile: docker/Dockerfile
target: runner
image: florian-app:${IMAGE_TAG:-latest}
container_name: florian-worker
restart: unless-stopped
# --conditions=react-server: lib/storage.ts und lib/pdf.ts sichern sich mit
# dem Paket "server-only" ab. Unter node wirft dessen Standardexport;
# mit dieser Auflösungsbedingung greift der leere react-server-Export,
# und der Worker darf dieselben Bausteine benutzen wie die Anwendung.
command: ["node", "--enable-source-maps", "--conditions=react-server", "dist/worker/index.js"]
environment:
<<: *app-env
ROLLE: worker
healthcheck:
test: ["CMD", "node", "-e", "process.exit(0)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
app:
condition: service_started
networks:
- intern
logging: *logging
volumes:
postgres-data:
redis-data:
minio-data:
traefik-acme:
networks:
# Nur was von außen erreichbar sein muss, hängt hier drin.
edge:
name: florian-edge
# Datenbank, Redis, Gotenberg: intern, kein Zugang von außen.
intern:
name: florian-intern
internal: false