# Coolify-Deployment (Testserver/Prod, intern) — abgeleitet von docker-compose.yml. # Unterschiede zum lokalen Dev-Compose: # - keine host "ports": Coolify-Proxy routet die Domain intern auf app:3000 # - Konfiguration über Coolify-Env-Variablen statt env_file: .env # - Service "migrate": Init-Job (prisma migrate deploy + Rollen-Rechte-Sync), läuft einmalig VOR app # - kein mailhog (Dev); Service "worker" = SEC1 Mail-Worker (BullMQ/Redis-Queue) # In Coolify als "Docker Compose Location" -> docker-compose.coolify.yml setzen. # # Härtung (F-11/F-18): # - F-11: migrate nutzt die schlanke "migrate"-Stage (kein Next-Build), Images gepinnt. # - F-18: Backend-Dienste (postgres/redis/garage/migrate) im internen Netz ohne Egress; # nur app zusätzlich im default-Netz (Coolify-Proxy). Redis mit Passwort. # Überall no-new-privileges, cap_drop ALL (+gezielte cap_add), Ressourcenlimits. services: # Einmaliger Migrations-Job. Nutzt die schlanke "migrate"-Stage (Prisma-CLI/tsx + src, # aber OHNE next build/.next) — kleinere Angriffsfläche als die frühere builder-Stage. # Nach `migrate deploy` läuft der Rollen-Rechte-Sync (scripts/sync-role-permissions.ts): # additiv + idempotent, zieht neu eingeführte Rechte für bestehende Mandanten nach # (F-06 × F-10). Bei 0 Änderungen ein No-op. Betroffene Nutzer danach neu einloggen. # Optionaler Demo-Seed nur, wenn RUN_DEMO_SEED=true (Testserver). Idempotent (upserts). # In Produktion die Variable NICHT setzen; stattdessen BOOTSTRAP_ADMIN. migrate: build: context: . target: migrate command: sh -c "npx prisma migrate deploy && echo '>> Rollen-Rechte-Sync läuft…' && npx tsx scripts/sync-role-permissions.ts && echo '>> Vorlagen-Sync läuft…' && npx tsx scripts/sync-policy-templates.ts && if [ \"$$RUN_DEMO_SEED\" = \"true\" ]; then echo '>> Demo-Seed läuft…'; npx tsx prisma/seed.ts; fi && if [ \"$$BOOTSTRAP_ADMIN\" = \"true\" ]; then echo '>> Bootstrap-Admin läuft…'; npx tsx scripts/bootstrap-admin.ts; fi" environment: DATABASE_URL: ${DATABASE_URL} # Härtung §1: Passwort-Pepper (Argon2 `secret`). Seed/Bootstrap hashen Passwörter # → fail-secure, ohne gültigen 32-Byte-Hex wirft hashPassword und der Job scheitert. # Muss identisch zum app-Service sein (sonst verifizieren die Hashes später nicht). PASSWORD_PEPPER: ${PASSWORD_PEPPER} # Test: Demo-Seed. Produktiv: stattdessen BOOTSTRAP_ADMIN (Erst-Superadmin). RUN_DEMO_SEED: ${RUN_DEMO_SEED:-false} BOOTSTRAP_ADMIN: ${BOOTSTRAP_ADMIN:-false} BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} BOOTSTRAP_ADMIN_NAME: ${BOOTSTRAP_ADMIN_NAME:-} BOOTSTRAP_TENANT_NAME: ${BOOTSTRAP_TENANT_NAME:-} BOOTSTRAP_TENANT_SLUG: ${BOOTSTRAP_TENANT_SLUG:-} BOOTSTRAP_TENANT_SHORT: ${BOOTSTRAP_TENANT_SHORT:-} BOOTSTRAP_TENANT_SECTOR: ${BOOTSTRAP_TENANT_SECTOR:-} networks: - backend security_opt: - "no-new-privileges:true" cap_drop: - ALL deploy: resources: limits: cpus: "1.0" memory: 1G depends_on: postgres: condition: service_healthy restart: "no" app: build: context: . target: runner environment: DATABASE_URL: ${DATABASE_URL} # F-04: Row Level Security scharf. Der migrate-Job oben läuft bewusst # weiter mit der Owner-DATABASE_URL (BYPASSRLS). Nur der App-Prozess # verbindet als eingeschränkte Rolle isms_app (NOBYPASSRLS) über # RLS_DATABASE_URL und setzt app.tenant_id pro Transaktion. RLS_ENFORCED: ${RLS_ENFORCED:-false} RLS_DATABASE_URL: ${RLS_DATABASE_URL} # F-18: REDIS_URL wird AUS REDIS_PASSWORD abgeleitet (nicht separat setzen!) — so # bleibt sie immer konsistent zum `--requirepass` des redis-Dienstes und Coolify # kann sie nicht als „managed" sperren. In Coolify NUR REDIS_PASSWORD setzen. REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 AUTH_SECRET: ${AUTH_SECRET} # Härtung §1: Passwort-Pepper (Argon2 `secret`). Fail-secure — ohne gültigen # 32-Byte-Hex wirft jeder Login/hash. Muss IDENTISCH zum migrate-Job sein und # ⚠ nach dem Setzen nicht mehr ändern (sonst verifizieren bestehende Hashes nicht). PASSWORD_PEPPER: ${PASSWORD_PEPPER} # SEC3-d: TOTP-Secrets werden „at rest" mit AES-256-GCM verschlüsselt # (src/server/secret-crypto.ts). Der Schlüssel leitet sich aus MFA_ENC_KEY # ab, sonst aus AUTH_SECRET — daher OPTIONAL (leer = Fallback AUTH_SECRET). # ⚠ Nach dem Setzen nicht mehr ändern: macht bereits verschlüsselte # TOTP-Secrets unlesbar (kein Umschlüssel-Tool). MFA_ENC_KEY: ${MFA_ENC_KEY:-} AUTH_URL: ${AUTH_URL} # Hinter dem Coolify/Traefik-Proxy zwingend für Auth.js v5 (sonst UntrustedHost). AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true} S3_ENDPOINT: ${S3_ENDPOINT} S3_ACCESS_KEY: ${S3_ACCESS_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY} S3_BUCKET: ${S3_BUCKET} # S1: echter S3-Adapter (src/server/storage/adapter.ts). Region ist für MinIO # beliebig (Default us-east-1); überschreibbar für echtes AWS-S3. S3_REGION: ${S3_REGION:-us-east-1} # Lokaler (persistenter) Backup-Zielspeicher, wenn der Betreiber im Portal „Lokal" # wählt bzw. als Env-Fallback. Muss auf das gemountete `backups`-Volume zeigen, # sonst sind Sicherungen beim Redeploy flüchtig. DB-Config hat Vorrang vor dieser Var. BACKUP_LOCAL_DIR: ${BACKUP_LOCAL_DIR:-/app/.backups} AI_PROVIDER: ${AI_PROVIDER} AI_API_KEY: ${AI_API_KEY} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USER: ${SMTP_USER} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: ${SMTP_FROM} # Persistenter lokaler Backup-Zielspeicher (überlebt Redeploys). volumes: - backups:/app/.backups # app im internen Netz (Backend-Zugriff) UND im default-Netz (Coolify-Proxy-Routing). networks: - backend - default security_opt: - "no-new-privileges:true" cap_drop: - ALL deploy: resources: limits: cpus: "1.0" memory: 1G depends_on: postgres: condition: service_healthy redis: condition: service_started garage: condition: service_healthy garage-provision: condition: service_completed_successfully migrate: condition: service_completed_successfully healthcheck: # Dependency-frei (node ist im Image vorhanden); alles < 500 gilt als gesund test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode<500?0:1)).on('error',()=>process.exit(1))"] interval: 15s timeout: 5s retries: 10 start_period: 30s restart: unless-stopped # SEC1: Mail-Worker. Eigener Langläufer-Prozess, der die BullMQ/Redis-Queue # abarbeitet (Zustellung mit Retry/DLQ + täglicher Fristen-/Reminder-Job). # Ist REDIS_URL gesetzt, reiht die App Jobs ein und DIESER Worker stellt zu; # ohne REDIS_URL versendet die App inline und der Worker wird nicht gebraucht. # Nutzt das schlanke "migrate"-Image (tsx + src + Mail-Deps + Prisma-Client). # Owner-DATABASE_URL (kein RLS): der Worker arbeitet mandantenübergreifend. # Im default-Netz (Egress), damit der externe SMTP-Server erreichbar ist. worker: build: context: . target: migrate command: ["npx", "tsx", "scripts/mail-worker.ts"] environment: DATABASE_URL: ${DATABASE_URL} REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 # SMTP: fehlt die Konfiguration, bleibt der Worker aktiv und meldet den # Zustand (Jobs bleiben pending) — kein Neustart-Rennen beim Nachreichen. SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_SECURE: ${SMTP_SECURE:-} SMTP_USER: ${SMTP_USER} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: ${SMTP_FROM} # Absolute Links in Mails (Reset etc.): APP_BASE_URL, sonst AUTH_URL. AUTH_URL: ${AUTH_URL} networks: - backend - default security_opt: - "no-new-privileges:true" cap_drop: - ALL deploy: resources: limits: cpus: "0.5" memory: 512M depends_on: postgres: condition: service_healthy redis: condition: service_started migrate: condition: service_completed_successfully restart: unless-stopped # Backup-Worker: arbeitet die BullMQ-Queue "backup-ops" ab (Portal-Restore, # "Export jetzt", DSGVO-Zustellung). OHNE diesen Dienst bleiben angestoßene Jobs # dauerhaft auf "queued" (die App reiht nur ein, führt NICHT inline aus). # Owner-DATABASE_URL (kein RLS): Restore/Export laufen mandantenübergreifend und # brauchen einen Superuser-Owner (session_replication_role beim Restore). # Braucht das S3/MinIO-Backup-Bucket (S3_*) und den Backup-Schlüssel (BACKUP_ENC_KEY, # Fallback AUTH_SECRET). concurrency ist im Worker seriell (destruktiv). backup-worker: build: context: . target: migrate command: ["npx", "tsx", "scripts/backup-worker.ts"] environment: DATABASE_URL: ${DATABASE_URL} REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 AUTH_SECRET: ${AUTH_SECRET} # Pepper wird von der Fail-Secure-Startprüfung erwartet (assertSecureEnv). PASSWORD_PEPPER: ${PASSWORD_PEPPER} # Backup-Artefakt-Verschlüsselung (AES-256-GCM); ohne eigenen Key = AUTH_SECRET. BACKUP_ENC_KEY: ${BACKUP_ENC_KEY:-} # Objektspeicher für Backup-Artefakte + DSGVO-Pakete (S3/MinIO). Fehlt es, # nutzt der Store einen lokalen (ephemeren!) Ordner — für Prod S3_* setzen. S3_ENDPOINT: ${S3_ENDPOINT:-} S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} S3_SECRET_KEY: ${S3_SECRET_KEY:-} S3_BUCKET: ${S3_BUCKET:-} S3_REGION: ${S3_REGION:-} # Lokaler (persistenter) Backup-Zielspeicher — identischer Mount wie beim app-Service, # damit „Lokal" für Worker (schreibt Artefakte) und app (liest im Portal) dasselbe # Volume sieht. DB-Config aus dem Betreiber-Portal hat Vorrang vor dieser Var. BACKUP_LOCAL_DIR: ${BACKUP_LOCAL_DIR:-/app/.backups} AUTH_URL: ${AUTH_URL} # Gleiches persistentes Volume wie app (gemeinsamer lokaler Zielspeicher). volumes: - backups:/app/.backups networks: - backend - default security_opt: - "no-new-privileges:true" cap_drop: - ALL deploy: resources: limits: cpus: "0.5" memory: 512M depends_on: postgres: condition: service_healthy redis: condition: service_started migrate: condition: service_completed_successfully restart: unless-stopped # IM-D — Inbound-Mail-Worker (E-Mail-to-Ticket). Holt Mails vom Catch-all-Postfach # (vorfall-@in.certvia.de) per IMAP ab und legt daraus Vorfälle an bzw. reiht # unklare Mails in die Betreiber-Review. Kein Redis nötig (IMAP-Poller, keine Queue). # Ohne INCIDENT_IMAP_* beendet sich der Prozess sauber („nicht konfiguriert"). incident-inbound-worker: build: context: . target: migrate command: ["npx", "tsx", "scripts/incident-inbound-worker.ts"] environment: DATABASE_URL: ${DATABASE_URL} AUTH_SECRET: ${AUTH_SECRET} # Pepper wird von der Fail-Secure-Startprüfung erwartet (assertSecureEnv). PASSWORD_PEPPER: ${PASSWORD_PEPPER} # IMAP-Zugang des Catch-all-Postfachs. Fehlt es, beendet sich der Worker sauber. INCIDENT_IMAP_HOST: ${INCIDENT_IMAP_HOST:-} INCIDENT_IMAP_PORT: ${INCIDENT_IMAP_PORT:-993} INCIDENT_IMAP_USER: ${INCIDENT_IMAP_USER:-} INCIDENT_IMAP_PASSWORD: ${INCIDENT_IMAP_PASSWORD:-} INCIDENT_IMAP_TLS: ${INCIDENT_IMAP_TLS:-true} INCIDENT_IMAP_MAILBOX: ${INCIDENT_IMAP_MAILBOX:-INBOX} INCIDENT_IMAP_POLL_MS: ${INCIDENT_IMAP_POLL_MS:-60000} # Muss zur Catch-all-Subdomain passen (Ableitung der Intake-Adressen). INCIDENT_INTAKE_DOMAIN: ${INCIDENT_INTAKE_DOMAIN:-in.certvia.de} AUTH_URL: ${AUTH_URL} networks: - backend - default security_opt: - "no-new-privileges:true" cap_drop: - ALL deploy: resources: limits: cpus: "0.5" memory: 512M depends_on: postgres: condition: service_healthy migrate: condition: service_completed_successfully restart: unless-stopped postgres: image: pgvector/pgvector:0.8.0-pg16 environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - pgdata:/var/lib/postgresql/data networks: - backend security_opt: - "no-new-privileges:true" cap_drop: - ALL # Postgres-Entrypoint chownt das Datadir und wechselt per gosu auf den postgres-User. cap_add: - CHOWN - DAC_OVERRIDE - FOWNER - SETGID - SETUID deploy: resources: limits: cpus: "1.0" memory: 1G healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] interval: 5s timeout: 5s retries: 10 restart: unless-stopped redis: image: redis:7.4.2-alpine # F-18: Redis mit Passwort. REDIS_URL (app) muss dieses Passwort tragen. command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"] volumes: - redisdata:/data networks: - backend security_opt: - "no-new-privileges:true" cap_drop: - ALL # redis-alpine-Entrypoint chownt /data und wechselt per gosu auf den redis-User. cap_add: - CHOWN - SETGID - SETUID deploy: resources: limits: cpus: "0.5" memory: 256M restart: unless-stopped # Objektspeicher: Garage (S3-kompatibel) — ersetzt den früheren minio-Service # (MinIO Community EOL/Maintenance-Mode). Konzept: docs/KONZEPT-garage-migration.md. # Buckets/Keys werden NICHT über die S3-API angelegt, sondern vom Init-Job # "garage-provision" (Admin-API). Nichts nach außen (kein Traefik/ports:) — # rein clusterintern, wie minio zuvor. Version gepinnt (kein latest). garage: # Image mit eingebackener deploy/garage.toml (Dockerfile-Stage „garage"). KEIN # Bind-Mount der Config: Coolify legt relative Bind-Quellen sonst als Verzeichnis an # → Garage bekäme /etc/garage.toml als Ordner („IO error: Is a directory"). build: context: . target: garage # Secrets stehen NICHT in der eingebackenen deploy/garage.toml — der Daemon liest # rpc_secret/admin_token aus der Env (in Coolify literal setzen, nicht via ${…}). environment: GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET} GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN} volumes: # KRITISCH: meta enthält Bucket-/Key-/Layout-Definitionen → MUSS ins Host-Backup # (analog pgdata). Ohne meta sind die Objektdaten nicht adressierbar. - garage_meta:/var/lib/garage/meta - garage_data:/var/lib/garage/data networks: - backend security_opt: - "no-new-privileges:true" cap_drop: - ALL healthcheck: # Distroless-Image (nur /garage): der CLI-Status dient als Health-Signal. # Verbindet auf rpc_public_addr (loopback) → unabhängig von Compose-DNS. test: ["CMD", "/garage", "status"] interval: 10s timeout: 5s retries: 12 start_period: 10s deploy: resources: limits: cpus: "1.0" memory: 1G restart: unless-stopped # Idempotenter Provisioning-Job (analog "migrate", restart:no): stellt aus einer # leeren Garage Layout + Bucket + Access-Key + Rechte her (Garage-Admin-API, HTTP). # Läuft bei JEDEM Deploy; "already exists" = Erfolg. Ohne GARAGE_ADMIN_TOKEN No-op. # Nutzt das schlanke "migrate"-Image (Node/tsx) — kein Garage-Binary nötig. garage-provision: build: context: . target: migrate command: ["npx", "tsx", "scripts/garage-provision.ts"] environment: GARAGE_ADMIN_URL: http://garage:3903 GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN} # Der zu importierende Garage-Key = App-Key (App-Env und Garage synchron). S3_ACCESS_KEY: ${S3_ACCESS_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY} S3_BUCKET: ${S3_BUCKET:-isms-documents} # Optionaler separater Backup-Bucket (nur falls Backup-Store auf S3 statt lokal). BACKUP_S3_BUCKET: ${BACKUP_S3_BUCKET:-} # Single-Node-Layout (nominale Kapazität/Zone; für Prod ggf. anheben). GARAGE_ZONE: ${GARAGE_ZONE:-dc1} GARAGE_CAPACITY_BYTES: ${GARAGE_CAPACITY_BYTES:-100000000000} networks: - backend security_opt: - "no-new-privileges:true" cap_drop: - ALL deploy: resources: limits: cpus: "0.5" memory: 256M depends_on: garage: condition: service_healthy restart: "no" # ── ROLLBACK-NETZ (bis Garage abgenommen ist; danach entfernen) ─────────────── # Der alte minio-Service bleibt vorübergehend auskommentiert stehen, damit ein # Rollback ohne Repo-Archäologie möglich ist. NACH erfolgreicher Garage-Abnahme # diesen Block UND das Volume "miniodata" (unten) entfernen. # minio: # image: minio/minio:RELEASE.2025-04-22T22-12-26Z # command: server /data --console-address ":9001" # environment: # MINIO_ROOT_USER: ${MINIO_ROOT_USER} # MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} # volumes: # - miniodata:/data # networks: # - backend # security_opt: # - "no-new-privileges:true" # cap_drop: # - ALL # deploy: # resources: # limits: # cpus: "0.5" # memory: 512M # restart: unless-stopped networks: # Internes Netz ohne Egress/Host-Exposure für die Backend-Dienste. backend: internal: true # default: von Coolify für das Proxy-Routing zur app genutzt (mit Egress). default: volumes: pgdata: redisdata: # Garage-Objektspeicher. garage_meta ist KRITISCH (Bucket-/Key-/Layout-Definitionen) # und MUSS in die Host-Backup-Strategie (analog pgdata) — ohne meta sind die Daten # in garage_data nicht adressierbar. garage_meta: garage_data: # ROLLBACK: altes MinIO-Datenvolume. Nach abgenommener Garage-Migration entfernen. miniodata: # Persistenter lokaler Backup-Zielspeicher (Lane „Konfigurierbarer Backup-Zielspeicher"). # Von app + backup-worker unter /app/.backups gemountet, damit „Lokal" Redeploys überlebt. backups: