#229: operations manual (install, update, backup/restore, deletion, roles)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m33s
CI / Build container images (pull_request) Successful in 15s
CI / Auth e2e pack (pull_request) Successful in 9m20s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s

docs/vs-nfd/70-betriebshandbuch.md: installation as run on the real
stages (airgap variant explicitly pending #218-#221 with what already
exists as groundwork), update/rollback incl. the no-down-migrations
caveat, backup/restore with the ADR-0026 target allowlist and the
rehearsed monthly restore drill (evidence: logs on #98), the full
scheduler-job table (cadences verified against code), the deletion-and-
destruction chapter built on the #228 copy list (per content type:
what deletion reaches, what remains, immediate-destruction path,
decommissioning), and role separation incl. the deliberate limits of a
Site Admin and the honest note that Site Admin read-bypass makes the
content/platform split non-absolute app-side. Every procedure carries
its evidence level (erprobt / nicht geprobt / offen) — nothing claimed
above what was actually executed.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-31 09:57:16 +02:00
parent 1cf0458593
commit d641c5dc8a
2 changed files with 213 additions and 2 deletions

View File

@ -189,8 +189,9 @@ nach sich zieht.
Backup nur lokal · 3 AT · #227
- [x] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
Ports/Dienste, Vertrauensgrenzen · 4 AT · #228`60-sicherheitsdokumentation.md`
- [ ] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
Löschung und Vernichtung, Rollentrennung · 45 AT · #229
- [x] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
Löschung und Vernichtung, Rollentrennung · 45 AT · #229`70-betriebshandbuch.md`
(Airgap-Kapitel verweist auf offene #218#221)
- [ ] **Zuarbeit IT-Grundschutz** APP.3.1 und CON.11.1, je Anforderung
„Produkt / Betreiber / nicht anwendbar" · 34 AT · #230
- [ ] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231

View File

@ -0,0 +1,210 @@
# Betriebshandbuch (VS-NfD, Issue #229)
Zweck: der Betreiber führt die Instanz ohne uns — auch an dem Tag, an
dem etwas ausfällt. Dieses Handbuch bündelt die betrieblichen
Prozeduren und verweist für Schrittfolgen auf die maßgeblichen
Runbooks im Repository, statt sie zu duplizieren. Das Kapitel mit dem
größten VS-Gewicht ist **§5 Löschung und Vernichtung**; es baut auf der
vollständigen Kopienliste der Sicherheitsdokumentation auf
(`60-sicherheitsdokumentation.md` §5).
**Belegstufen.** Jede Prozedur nennt am Ende ihren Erprobungsstand:
- ✅ _erprobt_ — vom Autor bzw. automatisiert mindestens einmal real
ausgeführt, mit Beleg.
- ⚠️ _Mechanik vorhanden, nicht geprobt_ — implementiert und getestet,
aber noch nie im Ernstfall/als Übung durchgespielt.
- ⏳ _offen_ — kommt mit dem genannten Issue.
---
## 1 Installation
Referenz: `deploy/stages.md` (maßgebliche Schrittfolge),
`docs/self-hosting/README.md`.
1. Host-Voraussetzungen: Docker + Compose, ein Reverse Proxy mit TLS
(oder das mitgelieferte `caddy`-Compose-Profil).
2. `deploy/compose/docker-compose.yml` + `.env` (Vorlage
`.env.example`; **niemals** eine echte `.env` ins Repo — CI-Zaun
#198) auf den Host bringen; `COMPOSE_PROJECT_NAME`, Ports
(`WEB_PORT`/`API_PORT`/`COLLAB_PORT`), `POSTGRES_PASSWORD`, Secrets
setzen.
3. `docker compose pull && docker compose up -d` — die api wendet
Migrationen beim Start selbst an (`MIGRATE_ON_START`); es gibt
keinen separaten Migrationsschritt.
4. Erststart: der Setup-Wizard (Issue #80) legt das Admin-Konto an und
verriegelt sich danach dauerhaft; bis zum Abschluss ist nur
`/setup/*` erreichbar.
5. Reverse Proxy: `/` → web, `/api` → api, `/collab` → collab mit
WebSocket-Upgrade (Portliste: Sicherheitsdokumentation §2).
6. Für VS-NfD-Betrieb anschließend die Referenzkonfiguration aus dem
Härtungsleitfaden (`50-haertungsleitfaden.md`) anwenden und mit
dessen Checkliste verifizieren.
Belegstufe: ✅ erprobt — die Stages Test/Int/Prod auf ONE sind exakt
nach dieser Prozedur aufgesetzt und laufen produktiv (`deploy/stages.md`
dokumentiert die realen Instanzen).
**Airgap-/Offline-Variante:** ⏳ offen — Mirror-Verfahren (#218),
netzloser Build (#219), Testlauf in isolierter Umgebung (#220),
Offline-Update-Pfad (#221); Meilenstein M28. Bereits vorhanden als
Grundlage: alle Dritt-Images digest-gepinnt (#203), ein authoritativer
Node-Pin (#236), SBOMs je Release (#202).
## 2 Update und Rollback
Referenz: `docs/architecture/operations.md` §Update strategy,
`deploy/stages.md`.
- **Stages:** Merge auf `main` → CD baut Images, deployt Test, führt
Smoke-Tests aus, promotet Int. CD synct **keine** Compose-Dateien —
Compose-Änderungen werden von Hand auf den Host übernommen, sonst
räumt `--remove-orphans` manuell ergänzte Container ab.
Belegstufe: ✅ erprobt (läuft bei jedem Merge; zuletzt CD-Lauf 568).
- **Produktion:** ausschließlich getaggte Releases. `git tag vX.Y.Z`
baut die Versions-Images und führt die Update-Simulation als Gate
aus; `git tag prod-vX.Y.Z-initial` pinnt die Version in der
Prod-`.env` und rollt aus (readyz-Poll). Migrationen laufen beim
api-Start; **Rückwärtsmigrationen gibt es nicht** — Rollback setzt
deshalb ein DB-kompatibles Vorgängerimage voraus (Migrations-Kaveat;
im Zweifel Restore aus dem Backup-Set der Vorversion).
Belegstufe: ✅ erprobt (alle Prod-Versionen bis v0.12.0 so
ausgerollt).
- **Rollback:** `git tag prod-v<prev>-rollback1` pinnt die Vorversion
zurück. Belegstufe: ⚠️ Mechanik vorhanden, nicht geprobt — ein realer
Rollback war bisher nie nötig; die Übung steht aus.
- **Dritt-Image-Digest heben:** Prozedur `deploy/stages.md` §5a
(imagetools inspect → Compose-Referenz ändern → CI bestätigt → Stage-
Composes von Hand nachziehen → `docker inspect` verifiziert).
Belegstufe: ✅ erprobt (Rollout #203 am 31.07.2026).
- **Offline-Update:** ⏳ offen (#221, M28).
## 3 Backup und Restore
Referenz: `docs/operations/restore-runbook.md` (maßgeblich),
`deploy/backup-basel.md`, ADR 0015/0026.
- Nächtlich erzeugt der backup-Sidecar ein konsistentes Restore-Set
(`pg_dump -Fc` + Tar der Volumes `uploads`/`plugins`) auf dem
`backups`-Volume; Retention `BACKUP_RETENTION_DAYS` (Default 30,
Test/Int 7). Status in `status.json`, Fehler alarmieren per Mail
direkt via SMTP (bewusst nicht über die api — sie könnte das kaputte
Teil sein).
- **Zielbeschränkung (#192, ADR 0026):** Fernziele (rsync-Spiegel,
WebDAV) funktionieren nur gegen Hosts der **Deploy-Allowlist**
`BACKUP_ALLOWED_TARGETS`; eine leere Allowlist schaltet alle
Fernziele hart ab. Die Allowlist ist bewusst NICHT über die Admin-UI
änderbar (Rollentrennung, §6).
- **Restore:** `deploy/backup/restore.sh` fährt den Stack kontrolliert
herunter, spielt Dump + Volumes zurück und startet neu; Details und
Totalverlust-Szenario im Runbook.
- **Geprobter Restore:** ✅ erprobt — ein automatischer monatlicher
Restore-Drill (`drill-*`-Tag → `drill.yml`) stellt das jüngste
Prod-Set in einer Wegwerf-Umgebung wieder her und prüft Inhalte; das
Protokoll jedes Laufs steht als Kommentar an Issue #98.
Backups sind unverschlüsselt by design — Datenträgerschutz ist
Plattformsache (Abgrenzungserklärung).
## 4 Wartungsjobs (Scheduler)
Alle Jobs laufen in der api (in-app Scheduler, `jobs`-Tabelle), sind im
Site-Admin-Systempanel sichtbar und dort manuell auslösbar (auditiert
als `job.triggered`). Aktueller Bestand (9):
| Job | Rhythmus | Wirkung |
| -------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `trash-purge` | täglich | endgültiges Löschen abgelaufener Papierkorb-Seiten **und** -Teiche (`trash.retentionDays`, #31/#193) |
| `orphan-file-sweep` | täglich | verwaiste Uploads entfernen (#194); trägt nachts auch den SHA-256-Backfill (#199) |
| `page-compaction` | stündlich | `page_updates`-Log in den Zustand mergen |
| `version-thinning` | täglich | automatische Versions-Snapshots ausdünnen |
| `audit-retention` | täglich | `audit_log` nach `audit.retentionDays` beschneiden (#196), Lücke selbst auditiert (`audit.pruned`) |
| `conversion-payload-prune` | täglich | Import-/Export-Payloads fertiger Jobs nullen (#233) |
| `mail-outbox-retention` | täglich | SENT/endgültig FAILED Outbox-Zeilen löschen (#234) |
| `data-export-purge` | stündlich | abgelaufene DSGVO-Datenexporte entfernen (#68) |
| `notification-digest` | alle 15 min (Versand nach Nutzer-Präferenz) | Benachrichtigungs-Digests versenden |
Der e2e-Zaun `apps/web/e2e/system.spec.ts` pinnt diese Zahl — ein
neuer Job ohne Handbuch-/Zaun-Anpassung wird rot.
## 5 Löschung und Vernichtung
Grundsatz: „Gelöscht" heißt in Dorfteich erst dann gelöscht, wenn alle
Kopien aus `60-sicherheitsdokumentation.md` §5 erreicht sind. Die
Tabelle nennt je Inhaltstyp, was die Löschung tut, welche Kopien sie
erreicht und was **stehen bleibt**.
| Vorgang | Wirkung | erreichte Kopien | Rückstände / Fristen |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Seite in den Papierkorb | Soft-Delete (`deleted_at`), aus Suchindex entfernt (#195) | Suchvektor sofort | Inhalt vollständig vorhanden, wiederherstellbar bis Purge |
| Seiten-Purge (automatisch nach `trash.retentionDays`, Default 30, oder manuell) | löscht Zustand, Update-Log, Content-Cache, Versionen, Kommentare, Attachments (Bytes + Quota), Watches; Kinder rücken auf | DB-Zeilen + `uploads`-Bytes | `page_links.target_slug` bleibt bewusst (#235, Restrisiko I-24); Backups/Spiegel bis Ablauf ihrer Retention; Export-/Endgeräte-Kopien organisatorisch |
| Teich-Purge (#193, automatisch/manuell, Site-Admin) | löscht alle Seiten samt Kaskade, Labels, Grants, Nutzungszähler, Plugin-Opt-ins, Conversion-Jobs, Files | wie oben, teichweit | wie oben |
| Nutzer löschen / DSGVO | Konto löschen bzw. Autorschaft pseudonymisieren (`user.pseudonymized`) | Identitätsdaten | von ihm erstellte Inhalte gehören dem Teich |
| Export-Payloads | `conversion-payload-prune` nullt `input`/`result` fertiger Jobs (Default 30 d) | DB | Job-Zeile bleibt für Status/Audit |
| Mail-Kopien | `mail-outbox-retention` löscht SENT/endgültig FAILED (Default 30 d) | DB | zugestellte Mails beim Empfänger (Restrisiko I-23) |
| Backup-Sets | Retention des Sidecars bzw. des Spiegels | `backups`-Volume, Spiegel | ein gelöschter Inhalt lebt maximal bis zum Ablauf der längsten Backup-Retention weiter — bei sofortigem Vernichtungsbedarf Sets manuell löschen (Host-Zugriff) und Spiegel bereinigen |
| Audit-Trail | `audit-retention` (Default 365 d) | DB | Metadaten, nie Inhalt |
**Sofortige Vernichtung einzelner Inhalte** (über die Fristen hinaus):
Seite manuell purgen (Papierkorb → endgültig löschen), danach auf dem
Host die Backup-Sets der Aufbewahrungskette löschen bzw. den Spiegel
bereinigen und Endgeräte-/Exportkopien organisatorisch einsammeln.
Belegstufe Purge-Pfade: ✅ erprobt (laufen täglich produktiv; Purge-
Semantik durch Tests gepinnt). Belegstufe „Backup-Kette manuell
vernichten": ⚠️ nicht geprobt.
**Außerbetriebnahme einer Instanz:** `docker compose down -v` entfernt
Container und **alle benannten Volumes** (`db-data`, `uploads`,
`plugins`, `secrets`, `backups`, Caddy-Volumes); anschließend Spiegel-
Bestände löschen und Host-Datenträger nach Betreiber-Vorgabe
vernichten — die physische Vernichtung ist Plattformsache
(Abgrenzungserklärung). Belegstufe: ✅ erprobt für den Stack-Teil (der
stillgelegte Alt-VPS wurde so zurückgebaut; Datenträger dort noch als
Rollback-Reserve vorhanden).
## 6 Rollentrennung
| Aufgabe | braucht |
| --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
| Instanz-Settings, Nutzer-/Quota-Verwaltung, Plugins, Wartungsjobs anstoßen, Teich-Purge, Audit-Panel | **Site-Admin** (App-Rolle) |
| Deploy, `.env`/Compose ändern, Backup-Allowlist, Secrets-Volume, Restore, Backup-Sets vernichten, DB-Direktzugriff, Log-Collector | **Plattformzugriff** (Host) |
| Release nach Prod | Plattformzugriff + Freigabeprozess (Tag) |
Was ein Site-Admin **nicht** kann (bewusste Grenzen):
- die Backup-Zielliste ändern oder Fernziele aktivieren
(`BACKUP_ALLOWED_TARGETS` ist Deploy-Konfiguration, ADR 0026);
- Secrets lesen oder setzen, die im Secret-Store/der `.env` liegen
(die Settings-Tabelle trägt nie Secrets, security.md §Secrets);
- Deploy-/Composeänderungen, Image-Versionen, Migrationen auslösen;
- den Audit-Trail editieren (nur lesen; Retention läuft als Job und
protokolliert sich selbst);
- Backups herunterladen ist möglich (In-App-Restore-Pfad, #103), aber
ihre Vernichtung auf Host/Spiegel nicht.
Zu beachten: ein Site-Admin **liest** per Rollen-Bypass jeden Inhalt
der Instanz (permissions.md) — die Trennung „wer darf Inhalte sehen"
von „wer betreibt die Plattform" ist damit App-seitig bewusst NICHT
absolut; wo das nicht tragbar ist, ist es organisatorisch zu regeln
(Vier-Augen-Prinzip bei Vergabe der Site-Admin-Rolle). Der Lesetrail
für eingestufte Inhalte (M29, #222#225) macht solche Zugriffe
nachvollziehbar.
## 7 Monitoring und Störung
Referenz: `deploy/monitoring.md`, `docs/architecture/operations.md`
§Health & monitoring.
- `GET /api/v1/readyz` prüft DB, Migrationen, Konverter, Renderer,
Backup-Status; `healthz` je Container. Externe Überwachung der
Referenzinstanzen: Uptime-Kuma mit Alarmierung.
- Logs: alle Dienste loggen JSON auf stdout (Docker json-file mit
Rotation); Audit-Ereignisse mit `msg`-Präfix `audit: ` und
`severity`-Feld — Weiterleitung an SIEM/Syslog übernimmt der
Collector des Betreibers (Katalog:
`docs/architecture/audit-events.md`, v1.1, mit
Kompatibilitätsversprechen).
- Integritätsalarm: `file.integrity_failed` (critical) = Download-Hash
≠ Upload-Hash → Objekt als manipuliert/korrupt behandeln, Datei aus
Backup-Set wiederherstellen (Runbook), erneut laden; der
Audit-Eintrag trägt beide Hashes.