# Deployment: Produktivserver (Contabo-VPS via Coolify) > Zielumgebung: öffentlicher Contabo-VPS, eigene Coolify-Instanz, Git (Gitea) auf > demselben VPS, Domain **app.certvia.de** mit echtem Let's-Encrypt-TLS. > Ergänzt `docs/DEPLOY-COOLIFY.md` (Testserver). Deploy-Datei: `docker-compose.coolify.yml`. ## Unterschiede zum Testserver | Aspekt | Test (intern) | Produktiv (Contabo) | |--------|---------------|---------------------| | Erreichbarkeit | intern, HTTP, sslip.io | öffentlich, **HTTPS**, `app.certvia.de` | | Git-Quelle | internes Gitea | **Gitea auf dem VPS** (Release-Push) | | Erst-Admin | Demo-Seed (`RUN_DEMO_SEED=true`) | **Bootstrap-Admin** (`BOOTSTRAP_ADMIN=true`), **kein** Demo-Seed | | Secrets | Testwerte | **frische, starke** Secrets | | Backups | optional | **Pflicht** (pg_dump + Restore-Test) | | Branch | `dev` | `main` | --- ## Phase 1 — VPS-Grundlage 1. **Contabo-VPS** bereitstellen — **≥ 8 GB RAM** empfohlen (Next-Build ~1 GB + Stack). Bei weniger RAM zwingend Swap: ```bash fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile echo '/swapfile none swap sw 0 0' >> /etc/fstab free -h ``` 2. **Grundhärtung**: System aktualisieren, SSH-Key-Login (Passwort-Login aus), unattended-upgrades. 3. **Firewall** (ufw): nur benötigte Ports offen — `80`, `443`, SSH, sowie der Gitea-SSH-Port (s. Phase 2). 4. **Coolify installieren** (offizielles Installskript) — eigene, unabhängige Instanz. ## Phase 2 — Gitea auf dem VPS 5. Gitea als **Coolify-Ressource** (One-Click/Compose) aufsetzen, eigene Subdomain (z. B. `git.certvia.de`), TLS via Coolify. 6. Repo `ISMS-Tool` in diesem Gitea anlegen. 7. Lokal ein **zweites Remote** hinzufügen und beim Release `main` dorthin pushen: ```bash git remote add prod https://git.certvia.de//ISMS-Tool.git git push prod main ``` (Das ist der bewusste „Release-Push" — Prod deployt nur, was hier landet.) ## Phase 3 — DNS & Domain 8. Bei eurem DNS für `certvia.de` einen **A-Record** `app.certvia.de` → Contabo-IP anlegen (ebenso `git.certvia.de`). 9. Coolify vergibt/prüft danach automatisch das Let's-Encrypt-Zertifikat (VPS ist öffentlich erreichbar). ## Phase 4 — App-Ressource (Prod) in Coolify 10. **+ New Resource** → Git-Quelle = das **VPS-Gitea** (Deploy-Key), Repo `ISMS-Tool`, **Branch `main`**. 11. **Build Pack: Docker Compose**, **Compose Location: `docker-compose.coolify.yml`**. 12. **Domain** beim Service `app` = `https://app.certvia.de:3000` (Port 3000), Schema **https**. 13. **Environment-Variablen** aus `.env.prod.example` setzen (Runtime-Variablen). Kritisch: - Alle Secrets **frisch** (`AUTH_SECRET` neu: `openssl rand -base64 32`; neue DB-/MinIO-Passwörter). - `AUTH_URL=https://app.certvia.de` (exakt die App-Domain). - `RUN_DEMO_SEED=false` (bzw. weglassen). - **Bootstrap** (erster Deploy): `BOOTSTRAP_ADMIN=true` + `BOOTSTRAP_ADMIN_EMAIL/PASSWORD/NAME` + `BOOTSTRAP_TENANT_NAME/SLUG`. 14. **Persistent Storage** prüfen — v. a. `pgdata` (sonst Datenverlust bei Redeploy). ## Phase 5 — Erster Deploy & Bootstrap 15. **Deploy**. Ablauf: `postgres` (healthy) → `migrate` (migriert **und** legt via `BOOTSTRAP_ADMIN=true` den Erst-Admin an) → `app`. 16. In den **`migrate`-Logs** prüfen: `>> Bootstrap-Admin läuft…` → `✔ Bootstrap fertig: Mandant … + Plattform-Admin … angelegt.` 17. **Login** unter `https://app.certvia.de` mit den `BOOTSTRAP_ADMIN_*`-Zugangsdaten → **Passwort sofort ändern**. 18. Optional danach `BOOTSTRAP_ADMIN` auf `false` (das Skript ist idempotent, es schadet aber nicht, es an zu lassen). ## Phase 6 — Backups & Betrieb (vor „echtem" Go-Live) 19. **Postgres-Backups**: regelmäßiger `pg_dump` (oder Coolify-DB-Backup nach S3) + **Restore-Test** dokumentieren. `pgdata` ist das kritische Volume. 20. **MinIO-Backup**, sobald Datei-/Logo-Upload aktiv ist. 21. **Monitoring/Alerting**: App-Healthcheck ist im Compose vorhanden; zusätzlich Uptime-Check auf `https://app.certvia.de` und Log-Aggregation einrichten. 22. **Updates**: Betriebssystem/Coolify/Images regelmäßig aktualisieren. ## Phase 7 — Auto-Deploy (optional) 23. Gitea-Webhook (VPS-Gitea → Coolify) mit **Branch-Filter `main`**. Da Gitea und Coolify auf demselben Host sind, ggf. `GITEA__webhook__ALLOWED_HOST_LIST` weit genug setzen (vgl. Testserver-Erfahrung). --- ## Release-Fluss (Zusammenfassung) ``` Feature-Branch → dev # Entwicklung, Auto-Deploy Testserver dev → main # Release-Merge (nach Test-Freigabe) git push prod main # Release-Push ins VPS-Gitea → Prod-Deploy ``` ## Row Level Security scharfschalten (F-04) Postgres RLS ist als zweite Verteidigungslinie definiert (Policy `tenant_isolation` je Mandanten-Tabelle) und mit der Migration `rls_enforce` unter `FORCE ROW LEVEL SECURITY` + `WITH CHECK` scharf gestellt. Sie wird **env-gesteuert** aktiviert (`RLS_ENFORCED`), Default ist **aus** (Owner-Betrieb wie bisher). **Wie es funktioniert:** Bei `RLS_ENFORCED=true` verbindet sich der App-Prozess als eingeschränkte Rolle `isms_app` (NOBYPASSRLS) über `RLS_DATABASE_URL` und setzt `app.tenant_id` transaktionslokal pro Operation. Für diese Rolle greifen die Policies. Migrationen, Seed/Bootstrap und der mandantenübergreifende Login-Lookup laufen weiter über die **Owner-`DATABASE_URL`**. **Scharfschalten (einmalig):** 1. App-Rolle mit LOGIN + starkem Passwort versehen (nur einmal, kein Secret ins Repo): ```bash docker exec -it psql -U isms -d isms \ -c "ALTER ROLE isms_app WITH LOGIN PASSWORD '';" ``` 2. In Coolify die Env-Variablen setzen: - `RLS_ENFORCED=true` - `RLS_DATABASE_URL=postgresql://isms_app:@postgres:5432/isms?schema=public` 3. App-Service neu deployen. Fehlt `RLS_DATABASE_URL` bei aktivem Flag, bricht die App bewusst beim Start ab (fail secure). > **Warnung — Owner-Rolle muss BYPASSRLS/Superuser sein.** Die in `DATABASE_URL` > genutzte Owner-/Migrate-Rolle (`isms`) MUSS `BYPASSRLS` bzw. Superuser sein, > sonst sähe der Login (mandantenübergreifend) **keine** Nutzer und Migrationen > könnten fehlschlagen. FORCE ist für diese Rolle unschädlich, weil BYPASSRLS die > RLS auch unter FORCE umgeht. > > **Warnung — ohne Kontext = null Zeilen.** `RLS_ENFORCED=true` NIE ohne korrektes > `RLS_DATABASE_URL` setzen: eine RLS-Rolle ohne gesetzten `app.tenant_id` sieht > gar keine Zeilen. Das Setzen des Kontexts übernimmt `dbForTenant` automatisch. Nachweis der Korrektheit lokal: `npx tsx scripts/test-rls-enforcement.ts`. ## Host-Encryption at-rest — Daten-Volume (`pgdata` + MinIO) > Umsetzung der Härtung §2 (`docs/KONZEPT-haertung.md`). **Ops-Runbook, kein App-Code.** > Schützt gegen **physischen Plattendiebstahl / VPS-Decommission** — die Volumes liegen > sonst im Klartext auf der Contabo-Platte (`pgvector`-Community-Postgres hat **kein TDE**). > **Kein** Schutz gegen Live-Kompromittierung des laufenden Servers (dafür greifen > App-Feld-Encryption, RLS, Zugriffskontrollen) — im Betrieb ist das Volume im RAM entschlüsselt. **Prinzip:** Ein **LUKS/dm-crypt**-Container (dm-crypt/`cryptsetup`) bzw. ein **provider-verschlüsseltes Block-Volume** trägt das gesamte Daten-Volume. Coolifys Persistent-Storage (`pgdata`, MinIO-Daten, ggf. Redis-AOF) wird auf dieses verschlüsselte Volume gelegt — damit sind `pgdata` **und** MinIO in einem Rutsch gedeckt. **Einrichtung (einmalig, vor dem ersten Deploy mit echten Daten):** 1. Separates Block-Volume/Partition bereitstellen (z. B. `/dev/sdb`) — **nicht** das Root-FS. 2. LUKS-Container anlegen und öffnen: ```bash cryptsetup luksFormat --type luks2 /dev/sdb # Passphrase vergeben (s. u.) cryptsetup open /dev/sdb cryptdata # -> /dev/mapper/cryptdata mkfs.ext4 /dev/mapper/cryptdata mkdir -p /srv/cryptdata && mount /dev/mapper/cryptdata /srv/cryptdata ``` 3. Coolifys Datenwurzel (bzw. die betroffenen Named-Volumes/Bind-Mounts für `pgdata` und MinIO) auf `/srv/cryptdata/...` legen und die Compose-`volumes` dorthin zeigen lassen. 4. **Passphrase / Key-File** ausschließlich im **Org-Passwortmanager** hinterlegen (Eintrag im `docs/SECRETS-REGISTER.md` führen) + versiegelte Offline-Kopie. **Nie** ins Repo, nie ins Backup-Bucket. **Boot-Unlock (dokumentierte Entscheidung nötig — Betreiber wählt):** - **A) Manuelles Unlock (empfohlen für Einzel-VPS, höchster Schutz):** Nach jedem Reboot meldet sich ein Operator per SSH an und führt `cryptsetup open` + `mount` (bzw. ein `systemd`-Gate vor den Docker-/Coolify-Start) aus. Kein Key auf der Platte → Diebstahl der Platte nützt nichts. Nachteil: **kein unbeaufsichtigter Reboot** (Downtime bis Unlock). - **B) Key-File auf getrenntem Medium / Netz-Unlock:** `crypttab`-Key-File auf einem separaten, nicht mit der Platte gestohlenen Träger, oder **dracut/clevis + Tang** (Network-Bound Disk Encryption) für auto-unlock, solange der Tang-Server erreichbar ist. Komfortabler, aber der Angreifer, der Platte **und** Key/Tang-Zugang hat, gewinnt. > **Betreiber-Entscheidung dokumentieren:** A oder B, wer die Passphrase hält, und wie der > Reboot-Ablauf aussieht (Runbook-Schritt „Nach Reboot: Volume entsperren, dann Coolify starten"). > Ohne Unlock startet Postgres/MinIO nicht — das ist beabsichtigt (fail-secure). ## Verschlüsselte Backups — pgBackRest / age / restic > Umsetzung der Härtung §3 + Backup-Konzept §9 (`docs/KONZEPT-backup-restore.md`). > **Entscheidung:** **client-seitige AES-256-Verschlüsselung mit einem Schlüssel pro Umgebung** > — das Artefakt ist verschlüsselt, **bevor** es S3/MinIO erreicht (at-rest + in-transit + > zero-knowledge vom Speicher). **Nicht** allein auf Storage-SSE verlassen (SSE gern zusätzlich). > Konsistent zur bestehenden App-Verschlüsselung (TOTP AES-256-GCM, Backup-Export AES-256-GCM). **Schichten (je Umgebung getrennte Schlüssel):** | Schutzobjekt | Werkzeug | Schlüssel | |---|---|---| | Cluster-Backup (Postgres, WAL/Base) | **pgBackRest** `repo-cipher-type=aes-256-cbc` + `repo-cipher-pass` → S3/MinIO | pgBackRest-Repo-Key je Umgebung | | Per-Tenant-Export + DSGVO-Pakete | **`age`** (X25519, encrypt-then-upload) | `age`-Keypair je Umgebung | | MinIO-Dateien (Uploads/Logos) | **restic** (eingebaute AES-256-Verschlüsselung) | restic-Repo-Passwort je Umgebung | **pgBackRest (Beispiel `pgbackrest.conf`, Repo auf MinIO/S3):** ```ini [global] repo1-type=s3 repo1-s3-endpoint= repo1-s3-bucket=certvia-pgbackrest-prod # eigener Bucket, NICHT der App-Bucket repo1-s3-key= # nur Backup-Zugang, getrennt vom App-MinIO-User repo1-s3-key-secret= repo1-cipher-type=aes-256-cbc repo1-cipher-pass= # aus Passwortmanager, NIE im Bucket repo1-retention-full=4 # Retention: s. u. [certvia] pg1-path=/srv/cryptdata/pgdata ``` **age (Per-Tenant-/DSGVO-Artefakte):** ```bash age-keygen -o /root/age-prod.key # Private Key -> Passwortmanager + offline # Public Recipient (age1...) als Env/Config für die Backup-Jobs: age -r age1 -o export.tenant.age export.tenant.tar ``` **restic (MinIO-Objektdaten):** ```bash export RESTIC_REPOSITORY=s3:/certvia-restic-prod export RESTIC_PASSWORD= # aus Passwortmanager restic backup /srv/cryptdata/minio restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune ``` **Verdrahtung in Coolify / Betrieb:** - **Scheduling:** Coolify-**Scheduled Task / Cron** je Job (pgBackRest full+incr, `age`-Export, restic) — Zugangsdaten als Coolify-Env-Secrets, **nicht** im Compose committen. - **Retention je Schicht dokumentieren** (DR-WAL/Base, Per-Tenant-Snapshots, DSGVO-Exporte) und dem **DSB** vorlegen — Löschfristen ergeben sich aus der DSGVO-Aufbewahrung. - **Getrennte Buckets/Zugänge:** Backup-Repos liegen in **eigenen** Buckets mit **eigenem** Zugang, nie im App-MinIO-Bucket und nie zusammen mit den Schlüsseln. - **Restore-Test regelmäßig + dokumentiert** (der Prod-Runbook fordert das bereits für `pgdata`, Phase 6): mindestens quartalsweise eine Wiederherstellung in eine Wegwerf-Umgebung, Ergebnis protokollieren. Ein ungetestetes Backup gilt als kein Backup. **Bezug zur App-Backup-Engine:** Der in `dev` integrierte App-Export/Restore verschlüsselt Artefakte bereits **client-seitig mit AES-256-GCM** über `BACKUP_ENC_KEY` (Fallback `AUTH_SECRET`), **ein Key pro Umgebung** (Backup-Konzept §9). Umgebungs-Secrets (Pepper/`MFA_ENC_KEY`/`BACKUP_ENC_KEY`) liegen **nicht** im Artefakt; der Restore setzt Owner=Superuser voraus (`session_replication_role`). pgBackRest/`age`/restic sind die **Ops-Ebene darunter** (Cluster/Host/Objektspeicher) — dieselbe Grundregel „ein Schlüssel pro Umgebung, getrennt vom Artefakt" gilt durchgängig. ## Schicht A — Cluster-Disaster-Recovery mit PITR (Go-Live-Runbook) > Umsetzung Backup-Konzept §2 (`docs/KONZEPT-backup-restore.md`). **Ops-Runbook, kein App-Code.** > Schicht A deckt den **gesamten** Cluster ab — auch die **globalen** Tabellen (`Identity`/ > Credentials, Kataloge, `PlatformAdmin`), die die mandanten-scoped App-Engine (Schicht B) > bewusst NICHT anfasst. Zweck: Totalausfall / Ransomware / menschlicher Massenfehler → > „DB auf Zeitpunkt T" (Point-in-Time-Recovery). **Bausteine:** **pgBackRest** (empfohlen) oder **wal-g** — periodisches **Voll-/Inkrement-Backup** plus kontinuierliches **WAL-Archiving** nach S3/MinIO, verschlüsselt (`repo-cipher`). Erst das WAL-Archiv macht PITR möglich (Replay bis zu einem Zeitpunkt zwischen zwei Base-Backups). ### A.1 Repo-Config (Ergänzung zu `pgbackrest.conf` oben) ```ini [global] # ... (repo1-* wie im Abschnitt „Verschlüsselte Backups" oben) ... repo1-retention-full=4 # 4 Voll-Backups vorhalten (s. Retention-Tabelle) repo1-retention-archive=8 # WAL für so viele Full-Sets behalten (PITR-Fenster) repo1-retention-archive-type=full start-fast=y archive-async=y # asynchrones Archiving (Durchsatz/latenztolerant) spool-path=/var/spool/pgbackrest [certvia] pg1-path=/srv/cryptdata/pgdata ``` ### A.2 WAL-Archiving in Postgres aktivieren (`postgresql.conf`) ```ini archive_mode = on archive_command = 'pgbackrest --stanza=certvia archive-push %p' wal_level = replica max_wal_senders = 3 ``` ```bash # Stanza einmalig anlegen + prüfen, dann erstes Voll-Backup: pgbackrest --stanza=certvia stanza-create pgbackrest --stanza=certvia check pgbackrest --stanza=certvia --type=full backup # Zeitplan (Coolify Scheduled Task / cron): # full wöchentlich pgbackrest --stanza=certvia --type=full backup # diff/inc täglich pgbackrest --stanza=certvia --type=incr backup ``` ### A.3 Restore / PITR-Prozedur (dokumentierter Ablauf) > **Achtung:** Cluster-weit — ersetzt den GESAMTEN Datenbestand aller Mandanten. Für einen > **einzelnen** Kunden ist der **Portal-Restore** (Schicht B, `/admin/[id]?restore=1`) das > richtige Werkzeug, nicht PITR. ```bash # 1. App + Postgres stoppen (kein Schreibzugriff während des Restore). docker compose stop app && systemctl stop postgresql # bzw. Coolify-Dienst anhalten # 2a. Vollständiger Restore auf den neuesten konsistenten Stand: pgbackrest --stanza=certvia --delta restore # 2b. ODER Point-in-Time-Recovery auf einen Zeitpunkt T: pgbackrest --stanza=certvia --delta \ --type=time --target="2026-08-11 14:30:00+02" \ --target-action=promote restore # 3. Postgres starten — spielt WAL bis zum Target und promotet dann. systemctl start postgresql # Recovery-Fortschritt in den Postgres-Logs verfolgen (…"recovery stopping before"…). # 4. Kohärenz prüfen (s. Restore-Kohärenz unten): Secrets der Zielumgebung müssen # zu den Daten passen (Pepper/MFA_ENC_KEY), sonst Login/MFA planmäßig zurücksetzen. # 5. App wieder starten, Smoke-Test (Login, ein Mandant, ein Upload). ``` - **Quartalsweiser Restore-Test PFLICHT:** PITR in eine **Wegwerf-Umgebung** wiederherstellen, Ergebnis protokollieren (der Go-Live fordert das ohnehin für `pgdata`). Ungetestetes Backup = kein Backup. - **Retention/PITR-Fenster** dem DSB vorlegen (s. Retention-Tabelle unten). ## MinIO-Objekt-Backup mit restic (vollwertig, Go-Live-Runbook) > Ersetzt das frühere „Best-Effort-Key-Kopieren" durch ein **vollwertiges, versioniertes, > verschlüsseltes** Objekt-Backup. MinIO hält Uploads/Logos **und** die App-Backup-Artefakte > (`/backups/…`) sowie die DSGVO-Pakete (`/dsgvo-exports/…`); ein > DB-Backup allein genügt nicht (Backup-Konzept §1/§3). ```bash export RESTIC_REPOSITORY=s3:/certvia-restic-prod # eigener Bucket, eigener Zugang export RESTIC_PASSWORD= # aus Passwortmanager, NIE ins Repo/Bucket export AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= restic snapshots || restic init # einmalig initialisieren # Zeitplan (Coolify Scheduled Task / cron), täglich: restic backup /srv/cryptdata/minio --tag minio --host certvia-prod restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune restic check --read-data-subset=5% # Integrität stichprobenweise prüfen # Restore (ganzes Repo oder gezielt ein Prefix): restic restore latest --target /srv/cryptdata/minio restic restore latest --target /tmp/recover --include '/srv/cryptdata/minio//uploads' ``` - **Getrennte Buckets/Zugänge:** restic-Repo **nicht** im App-MinIO-Bucket, Schlüssel **nie** zusammen mit den Artefakten. - **Konsistenz:** MinIO-Snapshot und DB-Backup laufen zeitnah; die App-Restore-Engine (Schicht B) stellt DB **und** den Mandanten-Prefix wieder her — beide Ebenen müssen vorhanden sein. ### Retention je Schicht (dem DSB vorzulegen — Platzhalter, DSB bestätigt) | Schicht | Objekt | Vorschlag | |---|---|---| | A (pgBackRest) | Voll-Backups | 4 Wochen (`repo1-retention-full=4`) | | A (pgBackRest) | WAL-Archiv / PITR-Fenster | bis zu 8 Full-Sets (`repo1-retention-archive`) | | B (App-Export) | Per-Tenant-Snapshots | 30–90 Tage (DSB) | | DSGVO | Zustellpakete (`dsgvo-exports/`) | Download-Link **1 h TTL**; ZIP kurzlebig, danach löschen | ### ⚠ Restore-Kohärenz (Vorbedingung im Restore-Runbook) > **`PASSWORD_PEPPER`, `MFA_ENC_KEY` und `BACKUP_ENC_KEY` sind Umgebungs-Secrets und stehen > NICHT im Backup-Artefakt.** Ein Restore in eine Umgebung mit **anderen** Secrets bricht: > - **anderer `PASSWORD_PEPPER`** → **alle** Passwort-Prüfungen schlagen fehl (kein Login). > - **anderes `MFA_ENC_KEY`** → TOTP-Secrets nicht entschlüsselbar → MFA-Prüfung bricht. > - **anderes `BACKUP_ENC_KEY`** → das AES-256-GCM-Artefakt lässt sich gar nicht entschlüsseln. > > **Vorbedingung vor jedem Restore:** Zielumgebung hält **exakt die Secrets, mit denen das > Backup erzeugt wurde**, oder es wird ein **Passwort-/MFA-Reset** (bzw. Neuverschlüsselung) > eingeplant. Deshalb: Secrets je Umgebung getrennt führen (`docs/SECRETS-REGISTER.md`) und die > passende Kopie **vor** dem Restore bereitstellen. Cross-Environment-Restore (prod→staging) ist > nur mit den prod-Secrets **oder** anschließendem Reset lauffähig. ## Sicherheits-Checkliste Go-Live - [ ] Frische, starke Secrets (nicht aus Test übernommen) - [ ] `RUN_DEMO_SEED` aus, **keine** Demo-Daten in Prod - [ ] Bootstrap-Admin-Passwort nach erstem Login geändert - [ ] HTTPS erzwungen, gültiges Zertifikat - [ ] `pgdata` persistent + Backup + Restore-Test - [ ] Firewall aktiv, SSH gehärtet - [ ] Gitea-Repo privat, Zugriff nur für das Team - [ ] `REDIS_PASSWORD` gesetzt und in `REDIS_URL` eingetragen (F-18) - [ ] Alle Images auf konkrete Tags gepinnt (kein `:latest`), CI grün (F-11) - [ ] **Host-Encryption** aktiv: Daten-Volume (`pgdata` + MinIO) auf LUKS/verschlüsseltem Volume, Boot-Unlock-Verfahren dokumentiert (A oder B) - [ ] **Verschlüsselte Backups**: pgBackRest (`aes-256-cbc`) + `age` + restic eingerichtet, Retention dem DSB vorgelegt, **Restore-Test** dokumentiert - [ ] **Secrets-Register** (`docs/SECRETS-REGISTER.md`) gepflegt: Ownership + Rotationsregel je Umgebung, Keys nie im Artefakt-Bucket, versiegelte Offline-Kopie - [ ] **Restore-Kohärenz** geprüft: Zielumgebung hält die zum Backup passenden `PASSWORD_PEPPER`/`MFA_ENC_KEY`/`BACKUP_ENC_KEY` (sonst Reset einplanen) --- ## Supply-Chain & Container-Härtung (F-11 / F-18) Diese Maßnahmen sind in `Dockerfile`, `docker-compose*.yml`, `.gitea/workflows/ci.yml` und `renovate.json` umgesetzt. ### F-11 — Supply Chain - **`npm ci` statt `npm install`** (Lockfile bindend, reproduzierbar). Base-Image ist **`node:22-slim`** (Debian/glibc) statt Alpine/musl — damit greifen die im Lockfile hinterlegten glibc-Optional-Deps (lightningcss/oxide/argon2/sharp) zuverlässig. - **npm 11 im Build gepinnt.** Das committete `package-lock.json` wurde mit npm 11 erzeugt; das in `node:22.14.0` gebündelte npm 10.9.2 löst `next@16.2.12 → @swc/helpers` anders auf und würde `npm ci` mit *„out of sync"* abbrechen. `RUN npm i -g npm@11.19.0` stellt den Resolver-Gleichstand her — **ohne** Lockfile-Änderung. > **Offene Folgeänderung (Dependency-Lane / F-12):** Sauberer wäre, das Lockfile mit > npm 10 neu zu erzeugen (`rm -rf node_modules package-lock.json && npm install` unter > Node 22), damit die npm-Pinnung entfallen kann. Das ist eine `package-lock.json`-Änderung > und gehört **nicht** in diese Infra-Lane. - **Image-Pins** (keine rollenden Tags): `node:22.14.0-slim`, `pgvector/pgvector:0.8.0-pg16`, `redis:7.4.2-alpine`, `minio/minio:RELEASE.2025-04-22T22-12-26Z`, `mailhog/mailhog:v1.0.1`. - **Schlanke `migrate`-Stage** statt `builder` für den Migrations-/Seed-Job: kein `next build`, kein Build-Cache. Sie enthält weiterhin devDeps (tsx/Prisma-CLI) + `src`, weil `prisma/seed.ts` und `scripts/bootstrap-admin.ts` aus `../src`/`@/server` importieren. Ein reiner Prisma-CLI-Container würde diese Jobs brechen — die Entkopplung von Seed/Bootstrap vom App-Code ist als App-seitige Folgeänderung empfohlen. ### SBOM Zwei Wege (der CI-Weg ist optional/nicht-blockierend hinterlegt): ```bash # a) aus dem npm-Baum (CycloneDX) — braucht ein valides Lockfile (s. F-12-Hinweis): npm ci --include=optional && npm sbom --sbom-format cyclonedx --omit dev > sbom.cyclonedx.json # b) aus dem gebauten Image (unabhängig vom npm-Baum), z. B. mit Syft: docker build -t isms:sbom . syft isms:sbom -o cyclonedx-json > sbom.image.cyclonedx.json ``` ### F-18 — Härtung & Netzsegmentierung - **Redis mit `requirepass`** (`REDIS_PASSWORD`, in `REDIS_URL` eingesetzt). - **Internes Netz** (`internal: true`) für `postgres`/`redis`/`minio`/`migrate` — kein Egress, kein Host-Exposure; nur `app` zusätzlich im default-Netz (Coolify-Proxy). - **`security_opt: no-new-privileges`, `cap_drop: [ALL]`** (gezielte `cap_add` nur für die Entrypoints von postgres/redis, die intern per gosu den Benutzer wechseln), sowie **`deploy.resources.limits`** (CPU/RAM) je Dienst. - **Dev-Compose**: Host-Ports nur noch auf `127.0.0.1` gebunden (Postgres/Redis/MinIO/Mailhog). ### CI & Renovate - **CI**: `.gitea/workflows/ci.yml` (Spiegel `.github/workflows/ci.yml`) fährt `npm ci → tsc --noEmit → lint → build`, dazu ein **`npm audit`-Gate** (`--omit=dev --audit-level=high`, hart) plus einen informativen Volllauf. > **Braucht einen aktivierten Gitea-Actions-Runner** — ohne registrierten Runner läuft > die Pipeline nicht an. - **Renovate** (`renovate.json`): wöchentliche, gruppierte Updates; Sicherheits-/OSV-Alerts sofort und priorisiert. **`next-auth`/`@auth/core` (Beta 5.x)** sind bewusst auf manuelles Review gesetzt (kein Auto-Merge) — Auth-Regressionen können „fail open" bedeuten (F-01). ### IM-D — E-Mail-Eingang für Vorfälle (Inbound / E-Mail-to-Ticket) Vorfälle können per E-Mail gemeldet werden. Kunden leiten Meldungen an eine kundenindividuelle **Intake-Adresse** `vorfall-@in.certvia.de` weiter; der `incident-inbound-worker` holt die Mails per IMAP ab und legt daraus Vorfälle an (bzw. reiht unklare Mails in die Betreiber-Review). Der Token trägt die Mandantenzuordnung (global eindeutig, nicht erratbar) — es gibt **kein Postfach je Kunde**, sondern ein Catch-all. **Globaler Setup (einmalig, DNS + Postfach):** 1. **Subdomain `in.certvia.de`** anlegen und als Mail-Domain einrichten. 2. **Catch-all-Postfach** für `*@in.certvia.de` → ein einzelnes IMAP-Postfach (alle `vorfall-@…` landen darin). MX-Record auf den Mailhost setzen. 3. **DKIM/SPF/DMARC** für `in.certvia.de` veröffentlichen (Empfang; die Absender-Authentizität der weitergeleiteten Kundenmails wird über deren DKIM + die pro Kunde gepflegte Absender-Allowlist geprüft — SPF bricht bei Weiterleitung und ist bewusst nur ein Signal). 4. **IMAP-Zugang** des Catch-all-Postfachs als Secrets hinterlegen (siehe unten). **Env (Dienst `incident-inbound-worker` in `docker-compose.coolify.yml`):** | Variable | Zweck | | --- | --- | | `INCIDENT_IMAP_HOST` / `INCIDENT_IMAP_PORT` | IMAP-Server des Catch-all-Postfachs (Default-Port 993) | | `INCIDENT_IMAP_USER` / `INCIDENT_IMAP_PASSWORD` | Zugangsdaten des Catch-all-Postfachs | | `INCIDENT_IMAP_TLS` | `true` (Default) für IMAPS; `false` nur für STARTTLS/Plain | | `INCIDENT_IMAP_MAILBOX` | Postfach/Ordner (Default `INBOX`) | | `INCIDENT_IMAP_POLL_MS` | Abholintervall in ms (Default 60000, min. 15000) | | `INCIDENT_INTAKE_DOMAIN` | Muss zur Catch-all-Subdomain passen (Default `in.certvia.de`) | Ohne `INCIDENT_IMAP_*` beendet sich der Worker **sauber** mit einer Meldung (kein Crash) — der übrige Betrieb ist davon unberührt. Verarbeitete Mails werden als `\Seen` markiert; zusätzlich schützt die **Message-ID-Dedupe** gegen Doppel-Tickets beim erneuten Abholen. **Provisionierung je Kunde (Betreiber-Portal):** Beim Onboarding (oder später) im Mandanten unter **E-Mail-Eingang** die Absender-Domänen (Allowlist) hinterlegen — die Intake-Adresse wird automatisch erzeugt und angezeigt. Der Kunde richtet die Weiterleitung ein; sobald die **erste Test-Mail** als Vorfall ankommt, springt der Status von *Weiterleitung ausstehend* auf *verifiziert* (manueller Override im Portal möglich). Offene Verifizierungen und zu prüfende Eingänge (kein/unbekannter Token, Allowlist-/DKIM-Fehlschlag) listet das Betreiber-Dashboard.