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

26 KiB
Raw Blame History

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:
    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

  1. Gitea als Coolify-Ressource (One-Click/Compose) aufsetzen, eigene Subdomain (z. B. git.certvia.de), TLS via Coolify.
  2. Repo ISMS-Tool in diesem Gitea anlegen.
  3. Lokal ein zweites Remote hinzufügen und beim Release main dorthin pushen:
    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

  1. Bei eurem DNS für certvia.de einen A-Record app.certvia.de → Contabo-IP anlegen (ebenso git.certvia.de).
  2. Coolify vergibt/prüft danach automatisch das Let's-Encrypt-Zertifikat (VPS ist öffentlich erreichbar).

Phase 4 — App-Ressource (Prod) in Coolify

  1. + New Resource → Git-Quelle = das VPS-Gitea (Deploy-Key), Repo ISMS-Tool, Branch main.
  2. Build Pack: Docker Compose, Compose Location: docker-compose.coolify.yml.
  3. Domain beim Service app = https://app.certvia.de:3000 (Port 3000), Schema https.
  4. 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.
  5. Persistent Storage prüfen — v. a. pgdata (sonst Datenverlust bei Redeploy).

Phase 5 — Erster Deploy & Bootstrap

  1. Deploy. Ablauf: postgres (healthy) → migrate (migriert und legt via BOOTSTRAP_ADMIN=true den Erst-Admin an) → app.
  2. In den migrate-Logs prüfen: >> Bootstrap-Admin läuft… → ✔ Bootstrap fertig: Mandant … + Plattform-Admin … angelegt.
  3. Login unter https://app.certvia.de mit den BOOTSTRAP_ADMIN_*-Zugangsdaten → Passwort sofort ändern.
  4. 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)

  1. Postgres-Backups: regelmäßiger pg_dump (oder Coolify-DB-Backup nach S3) + Restore-Test dokumentieren. pgdata ist das kritische Volume.
  2. MinIO-Backup, sobald Datei-/Logo-Upload aktiv ist.
  3. Monitoring/Alerting: App-Healthcheck ist im Compose vorhanden; zusätzlich Uptime-Check auf https://app.certvia.de und Log-Aggregation einrichten.
  4. Updates: Betriebssystem/Coolify/Images regelmäßig aktualisieren.

Phase 7 — Auto-Deploy (optional)

  1. 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):
    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:
    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):

[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):

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):

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)

[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)

archive_mode = on
archive_command = 'pgbackrest --stanza=certvia archive-push %p'
wal_level = replica
max_wal_senders = 3
# 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.

# 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).

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):

# 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.