# ============================================================================= # 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:-} 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 command: ["node", "--enable-source-maps", "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