Files
craftvia/docker-compose.coolify.yml
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

471 lines
18 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-<token>@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: