Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper, Berichtsassistent und Belegerfassung — alles gegen selbst betriebene Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was Menschen getan haben. Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal. Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion. Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen. Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und faster-whisper stehen im Compose-Profil "ki". 73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
544 lines
21 KiB
YAML
544 lines
21 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}
|
|
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
|