- 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>
26 KiB
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
- 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 - Grundhärtung: System aktualisieren, SSH-Key-Login (Passwort-Login aus), unattended-upgrades.
- Firewall (ufw): nur benötigte Ports offen —
80,443, SSH, sowie der Gitea-SSH-Port (s. Phase 2). - Coolify installieren (offizielles Installskript) — eigene, unabhängige Instanz.
Phase 2 — Gitea auf dem VPS
- Gitea als Coolify-Ressource (One-Click/Compose) aufsetzen, eigene Subdomain (z. B.
git.certvia.de), TLS via Coolify. - Repo
ISMS-Toolin diesem Gitea anlegen. - Lokal ein zweites Remote hinzufügen und beim Release
maindorthin pushen:(Das ist der bewusste „Release-Push" — Prod deployt nur, was hier landet.)git remote add prod https://git.certvia.de/<org>/ISMS-Tool.git git push prod main
Phase 3 — DNS & Domain
- Bei eurem DNS für
certvia.deeinen A-Recordapp.certvia.de→ Contabo-IP anlegen (ebensogit.certvia.de). - Coolify vergibt/prüft danach automatisch das Let's-Encrypt-Zertifikat (VPS ist öffentlich erreichbar).
Phase 4 — App-Ressource (Prod) in Coolify
- + New Resource → Git-Quelle = das VPS-Gitea (Deploy-Key), Repo
ISMS-Tool, Branchmain. - Build Pack: Docker Compose, Compose Location:
docker-compose.coolify.yml. - Domain beim Service
app=https://app.certvia.de:3000(Port 3000), Schema https. - Environment-Variablen aus
.env.prod.examplesetzen (Runtime-Variablen). Kritisch:- Alle Secrets frisch (
AUTH_SECRETneu: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.
- Alle Secrets frisch (
- Persistent Storage prüfen — v. a.
pgdata(sonst Datenverlust bei Redeploy).
Phase 5 — Erster Deploy & Bootstrap
- Deploy. Ablauf:
postgres(healthy) →migrate(migriert und legt viaBOOTSTRAP_ADMIN=trueden Erst-Admin an) →app. - In den
migrate-Logs prüfen:>> Bootstrap-Admin läuft…→✔ Bootstrap fertig: Mandant … + Plattform-Admin … angelegt. - Login unter
https://app.certvia.demit denBOOTSTRAP_ADMIN_*-Zugangsdaten → Passwort sofort ändern. - Optional danach
BOOTSTRAP_ADMINauffalse(das Skript ist idempotent, es schadet aber nicht, es an zu lassen).
Phase 6 — Backups & Betrieb (vor „echtem" Go-Live)
- Postgres-Backups: regelmäßiger
pg_dump(oder Coolify-DB-Backup nach S3) + Restore-Test dokumentieren.pgdataist das kritische Volume. - MinIO-Backup, sobald Datei-/Logo-Upload aktiv ist.
- Monitoring/Alerting: App-Healthcheck ist im Compose vorhanden; zusätzlich Uptime-Check auf
https://app.certvia.deund Log-Aggregation einrichten. - Updates: Betriebssystem/Coolify/Images regelmäßig aktualisieren.
Phase 7 — Auto-Deploy (optional)
- Gitea-Webhook (VPS-Gitea → Coolify) mit Branch-Filter
main. Da Gitea und Coolify auf demselben Host sind, ggf.GITEA__webhook__ALLOWED_HOST_LISTweit 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):
- 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>';" - In Coolify die Env-Variablen setzen:
RLS_ENFORCED=trueRLS_DATABASE_URL=postgresql://isms_app:<STARKES_PASSWORT>@postgres:5432/isms?schema=public
- App-Service neu deployen. Fehlt
RLS_DATABASE_URLbei aktivem Flag, bricht die App bewusst beim Start ab (fail secure).
Warnung — Owner-Rolle muss BYPASSRLS/Superuser sein. Die in
DATABASE_URLgenutzte Owner-/Migrate-Rolle (isms) MUSSBYPASSRLSbzw. 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=trueNIE ohne korrektesRLS_DATABASE_URLsetzen: eine RLS-Rolle ohne gesetztenapp.tenant_idsieht gar keine Zeilen. Das Setzen des Kontexts übernimmtdbForTenantautomatisch.
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):
- Separates Block-Volume/Partition bereitstellen (z. B.
/dev/sdb) — nicht das Root-FS. - 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 - Coolifys Datenwurzel (bzw. die betroffenen Named-Volumes/Bind-Mounts für
pgdataund MinIO) auf/srv/cryptdata/...legen und die Compose-volumesdorthin zeigen lassen. - Passphrase / Key-File ausschließlich im Org-Passwortmanager hinterlegen
(Eintrag im
docs/SECRETS-REGISTER.mdfü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. einsystemd-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_KEYundBACKUP_ENC_KEYsind 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_SEEDaus, keine Demo-Daten in Prod- Bootstrap-Admin-Passwort nach erstem Login geändert
- HTTPS erzwungen, gültiges Zertifikat
pgdatapersistent + Backup + Restore-Test- Firewall aktiv, SSH gehärtet
- Gitea-Repo privat, Zugriff nur für das Team
REDIS_PASSWORDgesetzt und inREDIS_URLeingetragen (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 cistattnpm install(Lockfile bindend, reproduzierbar). Base-Image istnode: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.jsonwurde mit npm 11 erzeugt; das innode:22.14.0gebündelte npm 10.9.2 löstnext@16.2.12 → @swc/helpersanders auf und würdenpm cimit „out of sync" abbrechen.RUN npm i -g npm@11.19.0stellt 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 installunter Node 22), damit die npm-Pinnung entfallen kann. Das ist einepackage-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 stattbuilderfür den Migrations-/Seed-Job: keinnext build, kein Build-Cache. Sie enthält weiterhin devDeps (tsx/Prisma-CLI) +src, weilprisma/seed.tsundscripts/bootstrap-admin.tsaus../src/@/serverimportieren. 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, inREDIS_URLeingesetzt). - Internes Netz (
internal: true) fürpostgres/redis/minio/migrate— kein Egress, kein Host-Exposure; nurappzusätzlich im default-Netz (Coolify-Proxy). security_opt: no-new-privileges,cap_drop: [ALL](gezieltecap_addnur für die Entrypoints von postgres/redis, die intern per gosu den Benutzer wechseln), sowiedeploy.resources.limits(CPU/RAM) je Dienst.- Dev-Compose: Host-Ports nur noch auf
127.0.0.1gebunden (Postgres/Redis/MinIO/Mailhog).
CI & Renovate
- CI:
.gitea/workflows/ci.yml(Spiegel.github/workflows/ci.yml) fährtnpm ci → tsc --noEmit → lint → build, dazu einnpm 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):
- Subdomain
in.certvia.deanlegen und als Mail-Domain einrichten. - Catch-all-Postfach für
*@in.certvia.de→ ein einzelnes IMAP-Postfach (allevorfall-<token>@…landen darin). MX-Record auf den Mailhost setzen. - DKIM/SPF/DMARC für
in.certvia.deverö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). - 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.