# ============================================================================= # 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} KARTE_STIL_URL: ${KARTE_STIL_URL:-} KARTE_BREITENGRAD: ${KARTE_BREITENGRAD:-48.5} KARTE_LAENGENGRAD: ${KARTE_LAENGENGRAD:-12.15} KARTE_ZOOM: ${KARTE_ZOOM:-13} 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:-} NTFY_ENABLED: ${NTFY_ENABLED:-false} NTFY_URL: ${NTFY_URL:-} NTFY_TOKEN: ${NTFY_TOKEN:-} NTFY_TIMEOUT: ${NTFY_TIMEOUT:-10} WEBPUSH_ENABLED: ${WEBPUSH_ENABLED:-false} WEBPUSH_PUBLIC_KEY: ${WEBPUSH_PUBLIC_KEY:-} WEBPUSH_PRIVATE_KEY: ${WEBPUSH_PRIVATE_KEY:-} WEBPUSH_KONTAKT: ${WEBPUSH_KONTAKT:-} BENACHRICHTIGUNG_LAUF: ${BENACHRICHTIGUNG_LAUF:-0 7 * * *} TERMIN_ERINNERUNG_TAGE: ${TERMIN_ERINNERUNG_TAGE:-7,1} BENACHRICHTIGUNG_VERSUCHE: ${BENACHRICHTIGUNG_VERSUCHE:-3} BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: ${BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE:-90} LLM_ENABLED: ${LLM_ENABLED:-false} LLM_BASE_URL: ${LLM_BASE_URL:-} LLM_API_KEY: ${LLM_API_KEY:-} LLM_MODELL: ${LLM_MODELL:-} LLM_TIMEOUT: ${LLM_TIMEOUT:-120} LLM_EINBETTUNG_MODELL: ${LLM_EINBETTUNG_MODELL:-nomic-embed-text} LLM_EINBETTUNG_DIMENSIONEN: ${LLM_EINBETTUNG_DIMENSIONEN:-768} LLM_VISION_MODELL: ${LLM_VISION_MODELL:-} WHISPER_ENABLED: ${WHISPER_ENABLED:-false} WHISPER_URL: ${WHISPER_URL:-} WHISPER_MODELL: ${WHISPER_MODELL:-base} WHISPER_SPRACHE: ${WHISPER_SPRACHE:-de} WHISPER_TIMEOUT: ${WHISPER_TIMEOUT:-300} DIKTAT_MAX_SEKUNDEN: ${DIKTAT_MAX_SEKUNDEN:-600} 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 und pgvector. Einziger Ort für relationale Daten. # # Eigenes Image, weil FLORIAN beides braucht und es kein offizielles gibt, # das beides mitbringt — siehe docker/postgres/Dockerfile. Beim ersten # `docker compose up` wird es gebaut; bei einer bestehenden Installation # genügt `docker compose build postgres` und ein Neustart des Dienstes. Das # Datenverzeichnis bleibt unberührt, die Hauptversion ändert sich nicht. # --------------------------------------------------------------------------- postgres: build: context: ./docker/postgres image: florian-postgres:${IMAGE_TAG:-17-3.5-pgvector} 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 # --------------------------------------------------------------------------- # Kartenserver (Phase 6). # # Nur mit Profil: "docker compose --profile karte up -d". FLORIAN läuft ohne # ihn — die Karte zeigt dann einen leeren Hintergrund, Hydranten und # Lagekarte funktionieren trotzdem. # # Gebraucht wird eine MBTiles-Datei des Ausrückegebiets unter # docker/karten/. Woher man sie bekommt, steht in der README; sie liegt # bewusst nicht im Repository — das sind je nach Zuschnitt hunderte Megabyte # bis Gigabyte, und jede Wehr braucht einen anderen Ausschnitt. # # Der Dienst hängt im internen Netz und im Edge-Netz: der Browser des # Anwenders holt die Kacheln direkt, nicht über die Anwendung. # --------------------------------------------------------------------------- tileserver: image: maptiler/tileserver-gl:v4.11.1 container_name: florian-tileserver restart: unless-stopped profiles: [karte] command: ["--port", "8080", "--public_url", "${KARTE_OEFFENTLICH:-http://localhost:8085/}"] volumes: - ./docker/karten:/data:ro ports: - "${KARTE_PORT:-8085}:8080" networks: - edge - intern logging: *logging # --------------------------------------------------------------------------- # Ollama — Sprachmodelle im eigenen Haus (Phase 8). # # Nur mit Profil: docker compose --profile ki up -d # # Es gibt bewusst KEINEN Rückfall auf einen Anbieter. Läuft dieser Container # nicht, fehlen die KI-Funktionen — sie werden nicht durch einen fremden # Dienst ersetzt. Ein Einsatzbericht spricht über Menschen in ihrer # schlechtesten Stunde; solche Texte gehören nirgendwo anders hin. # # Nach dem ersten Start müssen die Modelle geholt werden: # # docker compose exec ollama ollama pull qwen2.5:14b # Text # docker compose exec ollama ollama pull nomic-embed-text # Einbettungen # docker compose exec ollama ollama pull llama3.2-vision # Belege (optional) # # Auf einem Rechner ohne Grafikkarte läuft das Einbettungsmodell noch flott, # das Textmodell zäh und das Vision-Modell praktisch nicht. Deshalb sind alle # drei einzeln konfigurierbar: eine Wehr kann das Wiki durchsuchbar machen, # ohne Berichte schreiben zu lassen. # # Für eine Nvidia-Karte zusätzlich das Compose-Fragment aus dem README # einbinden (deploy.resources.reservations.devices) — es steht nicht fest # hier drin, weil es ohne Karte den Start verhindert. # --------------------------------------------------------------------------- ollama: image: ollama/ollama:0.5.7 container_name: florian-ollama restart: unless-stopped profiles: [ki] environment: # Modelle bleiben geladen, statt nach fünf Minuten aus dem Speicher zu # fallen. Wer zweimal am Tag einen Bericht schreibt, wartet sonst jedes # Mal auf das Nachladen. OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-30m} OLLAMA_HOST: "0.0.0.0:11434" volumes: - ollama-data:/root/.ollama ports: - "${OLLAMA_PORT:-11435}:11434" networks: - intern logging: *logging # --------------------------------------------------------------------------- # faster-whisper — Spracherkennung fürs Diktat (Phase 8). # # Nur mit Profil: docker compose --profile ki up -d # # OpenAI-kompatible Schnittstelle, aber vollständig lokal. Das Modell wird # beim ersten Aufruf geholt und dann behalten. # # WHISPER_MODELL steuert die Größe: "tiny" läuft auf jedem Bürorechner, # "base" ist der brauchbare Kompromiss, "small" erkennt Fachbegriffe # merklich besser und will dafür Zeit oder eine Grafikkarte. # --------------------------------------------------------------------------- whisper: image: fedirz/faster-whisper-server:latest-cpu container_name: florian-whisper restart: unless-stopped profiles: [ki] environment: WHISPER__MODEL: ${WHISPER_MODELL:-base} WHISPER__INFERENCE_DEVICE: ${WHISPER_GERAET:-cpu} # Nach zehn Minuten ohne Anfrage den Speicher freigeben. Eine Wehr # diktiert nicht stündlich. WHISPER__TTL: ${WHISPER_TTL:-600} ENABLE_UI: "false" volumes: - whisper-data:/root/.cache/huggingface ports: - "${WHISPER_PORT:-8087}:8000" networks: - intern logging: *logging # --------------------------------------------------------------------------- # ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7). # # Nur mit Profil: docker compose --profile push up -d # # Warum das der interessanteste Push-Weg für eine Wehr ist: kein Konto, keine # Telefonnummer, kein Telefonbuchabgleich. Man abonniert in der App ein # Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. # # Der Container ist winzig und speichert standardmäßig nichts dauerhaft: # eine zugestellte Meldung ist erledigt. Wer sie nachlesen können will, # schaltet den Cache ein (siehe ntfy-Dokumentation) — dann liegen die Texte # allerdings auch dort und nicht nur in FLORIAN. # # ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest # mit. Deshalb schlägt FLORIAN Namen mit Zufallsanteil vor, und deshalb # steht NTFY_TOKEN bereit, falls der eigene ntfy Zugriffsschutz bekommt. # --------------------------------------------------------------------------- ntfy: image: binwiederhier/ntfy:v2.11.0 container_name: florian-ntfy restart: unless-stopped profiles: [push] command: ["serve"] environment: NTFY_BASE_URL: ${NTFY_OEFFENTLICH:-http://localhost:8086} NTFY_LISTEN_HTTP: ":80" # Keine Anmeldung nötig, um zu lesen und zu schreiben — geschützt ist # das Ding durch die Unerratbarkeit der Themennamen und dadurch, dass es # im eigenen Netz steht. Wer es ins Internet stellt, setzt # NTFY_AUTH_DEFAULT_ACCESS auf "deny-all" und vergibt Token. NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_ZUGRIFF:-read-write} NTFY_BEHIND_PROXY: "true" ports: - "${NTFY_PORT:-8086}:80" networks: - edge - 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: # Phase 8: die Modelle. Sie sind mehrere Gigabyte groß und sollen einen # Neustart des Containers überleben — sonst werden sie jedes Mal neu geholt. ollama-data: whisper-data: 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