Schnellerfassung im Alarmfall, sechs Zeitstempel mit Ausrueckzeit, Anfahrt und Hilfsfrist, Staerke nach FwDV 3, Personalvorschlag mit Qualifikationsstand, Einsatzbericht als PDF, Jahresexport fuer die Gemeinde, Statistik und Uebungen mit Anwesenheitsliste. Dazu das Einsatzportal fuer Fremdwehren: ein signierter Link ohne Anmeldung, zeitlich und mengenbegrenzt, der Stichwort, Ort, Zeiten und Lagemeldungen zeigt und Meldungen mit Foto annimmt. Es sind die ersten oeffentlich erreichbaren Endpunkte in FLORIAN. Acht neue Tabellen, alle mit organisationId und Row Level Security. Ein freigegebener Einsatzbericht ist unveraenderlich; ein Datenbanktrigger setzt das durch. Koordinaten liegen doppelt — als Dezimalgrad und als PostGIS-Punkt, zusammengehalten von einem Trigger. 57 neue Tests (32 Unit, 14 gegen eine echte Datenbank, 11 E2E). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
350 lines
12 KiB
YAML
350 lines
12 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}
|
|
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
|