Files
craftvia/docs/_certvia-archiv/DEPLOY-PROD-CONTABO.md
T
msolarczekandClaude Opus 5 cadaedc6cc L10b Betrieb & Aufräumen: Deploy – craftvia-worker, CI-Testjob, DEPLOY.md, Certvia-Doku archiviert
- docker-compose.coolify(.prebuilt).yml: Service craftvia-worker (Target worker, Chromium,
  shm_size 1gb, gleiche Härtung), Craftvia-Variablen für app und worker.
- Dockerfile: worker-Stage mit HOME=/home/app (Chromium-Profil für non-root); lokaler
  docker build der Targets runner und worker erfolgreich, PDF-Erzeugung im Image geprüft.
- .env.example/.env.prod.example/.env.coolify.example: alle Craftvia-Variablen inkl. RLS,
  KI-Provider, PDF_CHROMIUM_PATH, OFFLINE_MAX_DAYS, API_RATE_LIMIT_*, AI_GENERATION_RETENTION_DAYS,
  AI_MONTHLY_TOKEN_LIMIT.
- CI (.github, .gitea): Job gate mit Postgres (pgvector) und Redis als Service: migrate deploy,
  seed, Passwort für craftvia_app, tsc, lint, build, npm run test.
- docs/craftvia/DEPLOY.md (aus den Certvia-Deploy-Docs abgeleitet): Architektur, Domains, Secrets,
  Worker, Migrationen, RLS-Aktivierung, Backup/Restore, KI, Rate Limits, Aufbewahrung, Smoke,
  Update/Rollback. build-and-push-images.sh baut craftvia-worker.
- Certvia-/ISMS-Dokumente aus docs/ nach docs/_certvia-archiv/ (mit README); Verweise in README.md
  und Skript-Kommentaren angepasst.

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

486 lines
26 KiB
Markdown
Raw 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.
# 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/<org>/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 <postgres-container> psql -U isms -d isms \
-c "ALTER ROLE isms_app WITH LOGIN PASSWORD '<STARKES_PASSWORT>';"
```
2. In Coolify die Env-Variablen setzen:
- `RLS_ENFORCED=true`
- `RLS_DATABASE_URL=postgresql://isms_app:<STARKES_PASSWORT>@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=<minio-oder-s3-endpoint>
repo1-s3-bucket=certvia-pgbackrest-prod # eigener Bucket, NICHT der App-Bucket
repo1-s3-key=<zugang> # nur Backup-Zugang, getrennt vom App-MinIO-User
repo1-s3-key-secret=<zugang-secret>
repo1-cipher-type=aes-256-cbc
repo1-cipher-pass=<PGBACKREST_REPO_KEY> # 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<recipient-prod> -o export.tenant.age export.tenant.tar
```
**restic (MinIO-Objektdaten):**
```bash
export RESTIC_REPOSITORY=s3:<endpoint>/certvia-restic-prod
export RESTIC_PASSWORD=<RESTIC_REPO_KEY> # 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
> (`<tenantId>/backups/…`) sowie die DSGVO-Pakete (`<tenantId>/dsgvo-exports/…`); ein
> DB-Backup allein genügt nicht (Backup-Konzept §1/§3).
```bash
export RESTIC_REPOSITORY=s3:<endpoint>/certvia-restic-prod # eigener Bucket, eigener Zugang
export RESTIC_PASSWORD=<RESTIC_REPO_KEY> # aus Passwortmanager, NIE ins Repo/Bucket
export AWS_ACCESS_KEY_ID=<restic-zugang> AWS_SECRET_ACCESS_KEY=<restic-secret>
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/<tenantId>/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-<token>@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-<token>@…` 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.