diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md
index 9b36183..3dd34e8 100644
--- a/docs/vs-nfd/20-massnahmenplan.md
+++ b/docs/vs-nfd/20-massnahmenplan.md
@@ -184,13 +184,14 @@ nach sich zieht.
- [x] **Abgrenzungserklärung §52 VSA** — welche Sicherheitsgrundfunktionen die
Anwendung _nicht_ erbringt und wem sie zufallen. Wichtigstes
Einzeldokument. · 3 AT · #226 → `40-abgrenzungserklaerung.md`
-- [ ] **Härtungsleitfaden** mit Referenzkonfiguration „VS-NfD-Betrieb":
- lokale Auth aus, Public-API aus, MCP aus, Feeds aus, Plugins aus,
- Backup nur lokal · 3 AT · #227
-- [ ] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
- Ports/Dienste, Vertrauensgrenzen · 4 AT · #228
-- [ ] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
- Löschung und Vernichtung, Rollentrennung · 4–5 AT · #229
+- [x] **Härtungsleitfaden** mit Referenzkonfiguration „VS-NfD-Betrieb":
+ lokale Auth aus (Zeile ⏳ bis #216), Public-API aus, MCP aus, Feeds aus,
+ Plugins aus, Backup nur lokal · 3 AT · #227 → \`50-haertungsleitfaden.md\`
+- [x] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
+ Ports/Dienste, Vertrauensgrenzen · 4 AT · #228 → `60-sicherheitsdokumentation.md`
+- [x] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
+ Löschung und Vernichtung, Rollentrennung · 4–5 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" · 3–4 AT · #230
- [ ] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231
diff --git a/docs/vs-nfd/40-abgrenzungserklaerung.md b/docs/vs-nfd/40-abgrenzungserklaerung.md
index 21fc2bd..382b0cb 100644
--- a/docs/vs-nfd/40-abgrenzungserklaerung.md
+++ b/docs/vs-nfd/40-abgrenzungserklaerung.md
@@ -313,5 +313,8 @@ Slug-Residuum falls in #235 so entschieden), führt die Restrisikoliste
gekürzt; die Erklärung ist erst dann uneingeschränkt gültig, wenn die
Delta-Liste leer ist oder alle Restpositionen in der Restrisikoliste
(#231) vom Betreiber gebilligt sind.
-- Querbezüge: Härtungsleitfaden (#227), Sicherheitsdokumentation (#228),
- Betriebshandbuch (#229), IT-Grundschutz-Zuarbeit (#230).
+- Querbezüge: Härtungsleitfaden (#227, `50-haertungsleitfaden.md`),
+ Sicherheitsdokumentation (#228, `60-sicherheitsdokumentation.md`),
+ Betriebshandbuch (#229, `70-betriebshandbuch.md`),
+ IT-Grundschutz-Zuarbeit (#230, `80-grundschutz-mapping.md`),
+ Restrisikoliste (#231, `90-restrisiken.md`).
diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md
new file mode 100644
index 0000000..6323c22
--- /dev/null
+++ b/docs/vs-nfd/50-haertungsleitfaden.md
@@ -0,0 +1,94 @@
+# Härtungsleitfaden — Referenzkonfiguration „VS-NfD-Betrieb" (Issue #227)
+
+Zweck: **eine** benannte Konfiguration, die ein Betreiber als Ganzes
+übernehmen kann. Jeder Eintrag nennt den exakten Schalter, den Wert und
+das **Warum** — wer abweicht, tut es wissentlich. Der Leitfaden macht
+zugleich die in M1/M2/M4 gebauten Schalter prüfbar.
+
+**Pflegeregel (verbindlich):** Jeder PR, der einen neuen Instanz- oder
+Deploy-Schalter einführt, ergänzt diesen Leitfaden **im selben PR** um
+dessen Referenzwert. Ein Schalter ohne Leitfaden-Zeile gilt im Review
+als unvollständig. (Gleiches Muster wie der Ereigniskatalog-Zaun #201.)
+
+Geltungsbereich: Konfiguration der Anwendung. Die Härtung der Plattform
+(Betriebssystem, Netz, Reverse Proxy, Datenträger) ist Betreibersache
+(Abgrenzungserklärung `40-abgrenzungserklaerung.md` — von dort wird
+hierher verwiesen; das IT-Grundschutz-Mapping
+`80-grundschutz-mapping.md` nimmt diese Referenzkonfiguration als
+Produkt-Beleg).
+
+---
+
+## 1 Referenzkonfiguration
+
+### 1.1 Instanz-Settings (Site-Admin → Einstellungen; Tabelle `instance_settings`)
+
+Nach jeder Änderung an Instanz-Settings die api neu starten — der
+Settings-Cache ist in-process (operations.md).
+
+| Setting | Referenzwert | Default | Warum |
+| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
+| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
+| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
+| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |
+| `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Hash-Pinning ist verschoben (#232, Restrisikoliste) — der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig. |
+| `classification.newPageDefault` | `vs_nfd` | `unclassified` | in einer VS-NfD-Instanz beginnt nichts unmarkiert (#204); die Vererbung (#205) hält den Baum konsistent. |
+| `classification.uploadPolicy` | `block` | `warn` | Anhänge können die Kennzeichnung im Inhalt nicht tragen (#212) — die Referenzkonfiguration lehnt Uploads auf eingestufte Seiten serverseitig ab (403 `classified_upload_blocked`, #213) statt nur zu warnen. |
+| `upload.svgPolicy` | `reject` | `sanitize` | SVG ist aktiver Inhalt; die Sanitisierung ist gut getestet, aber Ablehnen ist die kleinere Angriffsfläche. Abweichung vertretbar, wenn SVG gebraucht wird. |
+| `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). |
+| `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). |
+| `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). |
+| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. |
+
+### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
+
+| Variable | Referenzwert | Warum |
+| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). |
+| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. |
+| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. |
+| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). |
+| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). |
+| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. |
+
+### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
+
+| Schalter | Referenzwert (geplant) | Status |
+| -------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `auth.local.enabled` | `false` — lokale Passwort-Auth aus, Anmeldung nur über die Fremdauthentisierung der Behörde | ⏳ kommt mit #216 (M27); bis dahin bleibt lokale Auth der einzige Anmeldeweg und `auth.registrationMode=closed` + Session-Verkürzung sind die Kompensation. Zeile wird im #216-PR scharfgestellt. |
+
+## 2 Verifikations-Checkliste
+
+Auf der laufenden Instanz (ersetze `HOST`); Erwartung jeweils dahinter.
+Die vier 404-Prüfungen laufen unauthentifiziert:
+
+```sh
+curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/public/v1/ponds # 404 (api.enabled=false)
+curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/mcp # 404 (mcp.enabled=false)
+curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/public/IRGENDEIN-TEICH/feed.xml # 404 (feeds.enabled=false)
+curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/admin/plugins # 401/404, nie 200 ohne Session
+curl -s https://HOST/api/v1/readyz # status ok
+```
+
+Als Site-Admin (UI → Administration bzw. `GET /api/v1/admin/settings`):
+
+- [ ] Registrierung „geschlossen"; neue Seite entsteht mit Kennzeichnung
+ (Einstufung neuer Seiten = VS-NfD); Upload auf eingestufte Seite
+ wird abgelehnt (403).
+- [ ] Plugin-Verwaltung antwortet 404 (Kill-Switch aktiv).
+- [ ] Backup-Panel zeigt keine aktiven Fernziele; auf dem Host ist
+ `BACKUP_ALLOWED_TARGETS` leer bzw. exakt der freigegebene Spiegel.
+- [ ] Eine Testsitzung läuft nach `SESSION_IDLE_HOURS` Inaktivität ab.
+- [ ] Kennzeichnungs-Stichprobe: eingestufte Seite zeigt den Aufdruck in
+ Web, Druckvorschau und PDF-Export (Konventionen: Kommentare auf
+ Issue #228 bzw. `60-sicherheitsdokumentation.md` §3.5).
+
+## 3 Querbezüge
+
+Abgrenzungserklärung (`40-abgrenzungserklaerung.md`, verweist hierher);
+IT-Grundschutz-Mapping (`80-grundschutz-mapping.md`, nutzt dieses
+Profil als Produkt-Beleg); Betriebshandbuch (`70-betriebshandbuch.md`,
+Installation §1 wendet dieses Profil an); Restrisikoliste
+(`90-restrisiken.md` — Abweichungen von der Referenzkonfiguration
+gehören dorthin, wenn sie dauerhaft sind).
diff --git a/docs/vs-nfd/60-sicherheitsdokumentation.md b/docs/vs-nfd/60-sicherheitsdokumentation.md
new file mode 100644
index 0000000..0cc7231
--- /dev/null
+++ b/docs/vs-nfd/60-sicherheitsdokumentation.md
@@ -0,0 +1,277 @@
+# Sicherheitsdokumentation (VS-NfD, Issue #228)
+
+Zweck: das Dokument, das ein Prüfer zuerst liest — Architektur,
+Datenflüsse, Netzplan, Vertrauensgrenzen und die vollständige Liste
+aller Inhaltskopien. Es konsolidiert bestehende Referenzdokumente,
+erfindet nichts neu und ist **auf die Ziffer genau** an
+`deploy/compose/docker-compose.yml` ausgerichtet (die Compose-Datei ist
+die maßgebliche Dienst- und Portliste; weicht dieses Dokument ab, ist
+das ein Fehler in diesem Dokument).
+
+Einordnung: Dorfteich erbringt **keine Sicherheitsgrundfunktion**
+(§52 VSA, ADR 0019, Abgrenzungserklärung
+`40-abgrenzungserklaerung.md`). Diese Dokumentation beschreibt, was die
+Anwendung tut — Verschlüsselung, Netztrennung, Datenträgerschutz und
+Beweissicherung auf Plattformebene fallen dem Betreiber zu.
+
+Quell-Dokumente (englisch, maßgeblich für Details):
+`docs/architecture/security.md`, `deployment.md`, `data-model.md`,
+`permissions.md`, `realtime-collaboration.md`,
+`plugin-architecture.md`, `operations.md`, `audit-events.md`;
+Deploy-Sicht: `deploy/stages.md`, `deploy/compose/docker-compose.yml`.
+
+Pflegeregel: Änderungen an Diensten, Ports, Datenflüssen oder
+Speicherorten werden **im selben PR** hier nachgezogen (dieselbe Regel
+wie beim Härtungsleitfaden `50-haertungsleitfaden.md`).
+
+---
+
+## 1 Komponenten
+
+```mermaid
+flowchart LR
+ subgraph client [Endgerät]
+ B[Browser SPA]
+ end
+ subgraph host [Betreiber-Host / Reverse-Proxy-Zone]
+ RP[Reverse Proxy TLS]
+ end
+ subgraph frontend [Docker-Netz frontend]
+ W[web nginx statisch]
+ A[api NestJS]
+ C[collab Hocuspocus]
+ end
+ subgraph internal [Docker-Netz internal]
+ DB[(PostgreSQL 17.5)]
+ P[pandoc 3.6]
+ G[gotenberg 8]
+ BK[backup Sidecar]
+ end
+ SMTP[(SMTP-Relay extern)]
+ MIR[(Backup-Spiegel rsync/WebDAV, Allowlist)]
+
+ B -->|HTTPS 443| RP
+ RP -->|/| W
+ RP -->|/api| A
+ RP -->|/collab WebSocket| C
+ A --> DB
+ C --> DB
+ A -->|HTTP pandoc:3030| P
+ A -->|HTTP gotenberg:3000| G
+ A -->|Mail| SMTP
+ BK --> DB
+ BK -.->|nur bei Konfiguration + Allowlist| MIR
+ BK -->|Fehler-Mail| SMTP
+```
+
+| Dienst | Image (gepinnt, #203/#236) | Zweck | Privilegien |
+| ---------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| `web` | eigenes Image (nginx-unprivileged 1.27, uid 101) | statische SPA-Auslieferung | kein Volume, nur Netz `frontend`, kein DB-Zugang |
+| `api` | eigenes Image (Node 22.15.1-alpine, User `node`) | REST-API, Permissions, Scheduler-Jobs, Import/Export-Orchestrierung, Audit | Volumes `uploads`, `plugins`, `secrets`, `backups` (ro); Netze `frontend`+`internal`; DB-Vollzugriff |
+| `collab` | eigenes Image (Node, User `node`) | Echtzeit-Editing (Yjs/Hocuspocus), Persistenz der Dokumente | Netze `frontend`+`internal`; DB-Vollzugriff; keine Datei-Volumes |
+| `db` | `postgres:17.5-alpine@sha256:…` | einziger Datenbestand (außer Uploads) | nur Netz `internal`; Volume `db-data` |
+| `pandoc` | `pandoc/core:3.6@sha256:…` | Dokumentkonvertierung (Import/Export DOCX/ODT) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
+| `gotenberg` | `gotenberg/gotenberg:8@sha256:…` | HTML→PDF-Rendering (Chromium) | nur `internal`; zustandslos, kein Volume, kein DB-Zugang |
+| `backup` | eigenes Image | nächtlicher `pg_dump -Fc` + Volume-Tar, Retention, optionaler Spiegel | nur `internal`; Volumes `uploads`, `plugins`, `secrets` (ro), `backups` (rw); DB-Zugang |
+| `caddy` (Compose-Profil, optional) | `caddy:2.10-alpine@sha256:…` | TLS-Terminierung, wenn kein Host-Proxy existiert | Ports 80/443 nach außen; Netz `frontend` |
+
+Alle eigenen Images laufen als non-root (`USER node`); die Stages auf
+ONE nutzen einen Host-Reverse-Proxy (Caddy des Hosts), das
+`caddy`-Profil bleibt dort inaktiv.
+
+## 2 Netzplan (Ports und Protokolle)
+
+Maßgeblich: `deploy/compose/docker-compose.yml`. Alle
+Anwendungs-Portbindungen sind **auf 127.0.0.1 beschränkt** — von außen
+erreichbar ist ausschließlich der Reverse Proxy.
+
+| Von | Nach | Port/Protokoll | Exposition |
+| ----------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
+| Client | Reverse Proxy | 443/TCP HTTPS (80 nur Redirect) | **extern** |
+| Reverse Proxy | `web` | `127.0.0.1:${WEB_PORT:-8100}` → Container 8080, HTTP | Host-lokal |
+| Reverse Proxy | `api` | `127.0.0.1:${API_PORT:-8101}` → Container 3000, HTTP | Host-lokal |
+| Reverse Proxy | `collab` | `127.0.0.1:${COLLAB_PORT:-8102}` → Container 3000, HTTP + WebSocket-Upgrade | Host-lokal |
+| `api`/`collab`/`backup` | `db` | 5432/TCP (nur Docker-Netz `internal`) | intern |
+| `api` | `pandoc` | `http://pandoc:3030` (nur `internal`) | intern |
+| `api` | `gotenberg` | `http://gotenberg:3000` (nur `internal`) | intern |
+| `api`, `backup` | SMTP-Relay | vom Betreiber konfiguriert (`SMTP_*`) | **ausgehend extern** |
+| `backup` | Spiegel-Ziel | rsync über SSH (Port konfigurierbar) bzw. WebDAV/HTTPS — **nur** an Hosts der Deploy-Allowlist `BACKUP_ALLOWED_TARGETS` (ADR 0026, #192); leere Allowlist = alle Fernziele hart aus | **ausgehend extern, allowlist-beschränkt** |
+| optional `caddy` | Client | 80/443 | extern (nur wenn Profil aktiv) |
+
+Es gibt **keine eingehenden** Verbindungen außer über den Reverse
+Proxy. `db`, `pandoc`, `gotenberg`, `backup` haben keinerlei
+Portbindung an den Host.
+
+## 3 Datenflüsse
+
+### 3.1 Authentifizierung
+
+```mermaid
+sequenceDiagram
+ participant B as Browser
+ participant A as api
+ participant DB as PostgreSQL
+ B->>A: POST /api/v1/auth/login (Origin-Header Pflicht, fail-closed CSRF #189)
+ A->>DB: user_identities (Argon2id-Hash prüfen), rate_limits (10/min/IP)
+ A-->>B: Set-Cookie dt_session (HttpOnly, Secure, SameSite=Lax)
+ Note over A,DB: Session-Row: gehashte Id, expiresAt (SESSION_ABSOLUTE_HOURS),
Idle-Grenze (SESSION_IDLE_HOURS, #190); Audit auth.login_*
+```
+
+Externe Authentisierung (OIDC/Proxy-Header) ist geplant (M27,
+#214–#217) und ändert diesen Fluss; bis dahin ist lokale
+Passwort-Authentifizierung der einzige Weg.
+
+### 3.2 Editieren (Echtzeit)
+
+```mermaid
+sequenceDiagram
+ participant B as Browser
+ participant A as api
+ participant C as collab
+ participant DB as PostgreSQL
+ B->>A: GET /pages/:id/collab-token (Guard: Read-Recht)
+ A-->>B: Kurzlebiges Token (60 s TTL, HKDF-Subkey aus COLLAB_TOKEN_SECRET, #188), mode rw/ro
+ B->>C: WebSocket /collab + Token
+ C->>C: Token-Verifikation (shared token-crypto), rw nur bei Write-Grant
+ B-->>C: Yjs-Updates (CRDT)
+ C->>DB: debounced Persist: pages.ydoc_state + page_updates-Log,
abgeleiteter page_content_cache (plain/markdown/html/outline/tsvector)
+ A-->>C: Rechteänderungen via Postgres LISTEN/NOTIFY (Kanal-Konstanten shared) → close(4205)
+```
+
+### 3.3 Export (PDF/DOCX/ODT/ZIP)
+
+```mermaid
+sequenceDiagram
+ participant B as Browser
+ participant A as api
+ participant P as pandoc
+ participant G as gotenberg
+ B->>A: POST /pages/:id/export {format}
+ A->>A: conversion_jobs-Row (input = aufbereitetes Markdown/HTML,
bei eingestufter Seite Option {marking}, #208/#209)
+ A->>G: html → pdf (Kennzeichnung im Header/Footer-Template je Seite)
+ A->>P: gfm → docx/odt (reference-doc mit Kennzeichnung, in-Request-Datei)
+ A-->>B: Poll GET /jobs/:id → Download /jobs/:id/result
+ Note over A: Payloads werden nach conversion.payloadRetentionDays genullt (#233)
+```
+
+Der Pond-ZIP-Export streamt direkt aus `page_content_cache` +
+`uploads`-Volume (permissionsgefiltert) und enthält seit M26
+Frontmatter/Aufdruck, `manifest.json` und Begleitdateien für
+eingestufte Inhalte (#210/#212).
+
+### 3.4 Backup
+
+```mermaid
+sequenceDiagram
+ participant BK as backup-Sidecar
+ participant DB as PostgreSQL
+ participant V as Volumes uploads/plugins
+ participant M as Spiegel (optional)
+ BK->>DB: nächtlich pg_dump -Fc
+ BK->>V: tar der Volumes (lesend)
+ BK->>BK: Restore-Set aufs backups-Volume, Retention (BACKUP_RETENTION_DAYS)
+ BK-->>M: rsync/WebDAV NUR an BACKUP_ALLOWED_TARGETS (ADR 0026); leer = aus
+ BK-->>BK: status.json; Fehler-Mail direkt via SMTP
+```
+
+Backups sind **unverschlüsselt by design** (ADR 0015/0019) —
+Datenträger- und Transportschutz ist Plattformsache
+(Abgrenzungserklärung §2).
+
+### 3.5 Lesekanäle
+
+Jeder Kanal, über den Seiteninhalt die Anwendung verlässt (zugleich die
+Instrumentierungsliste für den Lesetrail, #222):
+
+| Kanal | Pfad | Rechteprüfung | Kennzeichnung (M26) |
+| -------------------------- | -------------------------------- | -------------------------------------- | ----------------------------------- |
+| SPA-Seitenansicht | `GET /api/v1/pages/:id` u. a. | Guard (shared Resolver) | Banner oben+unten (#206) |
+| Public-/No-JS-Ansicht | `GET /api/v1/public/:pond/:page` | `public`-Grant, sonst 404 | Banner oben+unten (#211) |
+| Public REST API | `/api/public/v1/**` | `api.enabled` + Pond-Opt-in + PAT | `classification`-Feld (#211) |
+| MCP-Endpoint | `/api/mcp` | `mcp.enabled` + Pond-Opt-in + PAT | wie Public API (gleiches Modell) |
+| Atom-Feeds | `…/feed.xml` | `feeds.enabled`; Grant bzw. Feed-Token | ``-Element (#211) |
+| Suche | `GET /api/v1/search` | per-Treffer-Resolution | Level je Treffer (#211) |
+| Attachment-Download | `GET /api/v1/media/:fileId` | Guard über Seite/Pond | Dateinamens-Präfix `VS-NfD_` (#212) |
+| Export (PDF/Office/ZIP/MD) | s. 3.3 | Guard; ZIP permissionsgefiltert | je Kanal (#207–#210) |
+| Collab-WebSocket | `/collab` | Token aus 3.2 | Inhalt = Editor-Ansicht (#206) |
+
+## 4 Vertrauensgrenzen
+
+1. **Client ↔ Instanz** (Internet/Behördennetz): TLS am Reverse Proxy
+ terminiert (Betreiber-Zone). Die Anwendung setzt die
+ Security-Header selbst (eigene Middleware, #197: CSP `script-src
+'self'`, `X-Frame-Options: SAMEORIGIN`, restriktives CORS);
+ Cookie-Mutationen sind Origin-pflichtig (fail-closed, #189).
+2. **Reverse Proxy ↔ Anwendungscontainer**: nur 127.0.0.1-Bindungen;
+ der Proxy ist die einzige Eintrittsstelle. Der geplante
+ Proxy-Header-/mTLS-Authentisierungspfad (#215) verschiebt die
+ Authentisierungs-Vertrauensgrenze an genau diese Stelle — bis dahin
+ trägt der Proxy nur Transport.
+3. **Anwendungs- ↔ Datenzone**: `db`, `pandoc`, `gotenberg`, `backup`
+ sind nur im Docker-Netz `internal` erreichbar; `web` hat keinerlei
+ Zugang dorthin.
+4. **Plugin-Sandbox** (ADR 0008): Plugin-Code läuft ausschließlich im
+ sandboxed iframe (eigene Origin-lose Umgebung, Message-Protokoll,
+ deklarierte Capabilities); serverseitig wird beim Install ein
+ Static-Gate erzwungen. Instanzweiter Kill-Switch `plugins.enabled`
+ (#200); Hash-Pinning ist bewusst verschoben (#232,
+ Restrisikoliste). Der Sandbox-Escape-Regressionstest (bösartiges
+ Fixture-Plugin) läuft in CI.
+5. **Ausgehende Kanäle**: SMTP (Betreiber-Relay) und Backup-Spiegel
+ (Allowlist, ADR 0026) sind die einzigen initiierten
+ Außenverbindungen. Es gibt keine Telemetrie, keine Update-Pings,
+ keine externen Font-/CDN-Ladungen (ADR 0016: Fonts self-hosted).
+6. **Betreiber-Plattform**: Host, Docker-Daemon, Volumes, Netzwerk und
+ Backups liegen außerhalb der Anwendungsverantwortung
+ (Abgrenzungserklärung).
+
+## 5 Vollständige Liste der Inhaltskopien
+
+Grundlage des Löschkonzepts im Betriebshandbuch
+(`70-betriebshandbuch.md` §5) — Löschen ist nur vollständig, wenn es
+jede dieser Kopien erreicht oder ihren Verbleib begründet.
+
+**In der Datenbank (Volume `db-data`):**
+
+| Ort | Inhalt | Lebenszyklus |
+| ------------------------------ | ------------------------------------------------- | ----------------------------------------------------- |
+| `pages.ydoc_state` | aktuelles Dokument (Yjs-Binärzustand) | bis Purge der Seite |
+| `page_updates` | inkrementelles Update-Log | Compaction; Purge löscht |
+| `page_content_cache` | Klartext, Markdown, HTML, Outline, **Suchvektor** | bei jedem Persist ersetzt; Purge löscht |
+| `page_versions` | eigenständige Snapshots (Historie) | bis Purge der Seite |
+| `comments` | Kommentartexte | Purge der Seite kaskadiert |
+| `conversion_jobs.input/result` | Export-/Import-Payloads (ganze Dokumente) | genullt nach `conversion.payloadRetentionDays` (#233) |
+| `mail_outbox` | Mail-Bodies (Digest-Titel!) | gelöscht nach `mail.outboxRetentionDays` (#234) |
+| `page_links.target_slug` | Slug-Text auch nach Ziel-Purge | bewusst behalten (#235, Restrisiko I-24) |
+| `audit_log` | Metadaten (nie Inhalt) | `audit.retentionDays` (#196) |
+| `attachments` (Row) | Metadaten + SHA-256 | Purge der Seite/des Ponds |
+
+**Auf Volumes:**
+
+| Ort | Inhalt | Lebenszyklus |
+| --------- | -------------------------------------------- | --------------------------------------------------------------- |
+| `uploads` | Attachment-Bytes | Purge löscht Datei + Quota; Orphan-Sweep (#194) räumt Verwaiste |
+| `backups` | nächtliche Restore-Sets (Dump + Volume-Tars) | `BACKUP_RETENTION_DAYS` |
+| `plugins` | Plugin-Pakete (kein Seiteninhalt) | Uninstall |
+| `secrets` | Secret-Store (SMTP etc., kein Seiteninhalt) | Betreiber |
+
+**Außerhalb der Instanz (nicht von Anwendungs-Löschung erreichbar —
+Löschkonzept muss sie benennen):**
+
+| Ort | Inhalt | Kontrolle |
+| ----------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
+| Backup-Spiegel (BASEL/WebDAV) | vollständige Restore-Sets | Betreiber; Retention auf Zielsystem |
+| Export-Artefakte | PDF/DOCX/ODT/ZIP beim Nutzer | organisatorisch (VS-Handhabung, Kennzeichnung M26) |
+| Browser-IndexedDB | Offline-Kopie zuletzt geöffneter Seiten | Endgeräteschutz (Restrisiko I-25) |
+| Feed-Reader / API-Konsumenten | abonnierte Inhalte | organisatorisch; Referenzkonfiguration schaltet Feeds/API ab |
+| Mails beim Empfänger | Digest-/Benachrichtigungstexte (Titel) | Restrisiko I-23; Referenzkonfiguration ohne SMTP |
+| Container-stdout-Logs | Metadaten inkl. Audit-Zeilen (nie Seiteninhalt, security.md §Logging) | Docker json-file mit Rotation; Collector des Betreibers |
+
+## 6 Kryptografie-Inventar (Bestand, kein Anspruch)
+
+Zur Einordnung (vollständige Liste: Abgrenzungserklärung §3): Argon2id
+für Passwort-Hashes, HMAC-signierte Kurzzeit-Tokens über `jose` mit
+HKDF-Zweckableitung (#188), SHA-256-Integritätshashes für Attachments
+(#199, fail-closed beim Download). Keine Inhalts- oder
+Backup-Verschlüsselung, kein eigenes Schlüsselmanagement — bewusst
+(§52 VSA).
diff --git a/docs/vs-nfd/70-betriebshandbuch.md b/docs/vs-nfd/70-betriebshandbuch.md
new file mode 100644
index 0000000..061ec78
--- /dev/null
+++ b/docs/vs-nfd/70-betriebshandbuch.md
@@ -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-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.