Files
serienbrief/serienbrief_konzept_directus.md
2026-05-20 09:24:11 +02:00

42 KiB
Raw Permalink Blame History

Architekturkonzept: Serienbrief-System mit Directus

Version: 1.0
Umgebung: Ubuntu Server 24.04 LTS, Docker Engine 27.x, Docker Compose V2
Zielgruppe: Erfahrener Linux-/Docker-Admin, Data Protection Officer im Gesundheitssektor (Österreich)
Stand: 2025


Inhaltsverzeichnis

  1. Kontextabgrenzung und Annahmen
  2. Architekturübersicht
  3. Stack-Vergleich: Option A vs. Option B
  4. Datenfluss im Detail
  5. Template-Management
  6. PDF-Rendering: Optionsvergleich
  7. Security und DSGVO-Compliance
  8. Docker Compose Grundstruktur
  9. Empfehlung und Entscheidungsbegründung

1. Kontextabgrenzung und Annahmen

Ausgangslage

  • Directus läuft bereits als Container (mit PostgreSQL-Backend)
  • Adressdaten, ggf. Gesundheitsreferenzen in Directus-Collections
  • Serienbrief-Nutzer sind interne Mitarbeitende (kein anonymer Zugriff)
  • Briefe werden als PDF ausgegeben; optionaler E-Mail-Versand ist Out-of-Scope dieses Konzepts
  • Kein Kubernetes; Docker Compose bleibt der Orchestrierer

Gesetzliche Rahmenbedingungen (Österreich)

Gesundheitsdaten fallen unter Art. 9 Abs. 1 DSGVO die höchste Schutzstufe. Relevante österreichische Ergänzungen:

  • DSG (Datenschutzgesetz) 2018 i.d.g.F. konkretisiert Art. 9-Ausnahmen
  • GTelG 2012 / ELGA für elektronische Gesundheitsdaten im engeren Sinn
  • Jede neue Verarbeitungsaktivität ist im Verzeichnis der Verarbeitungstätigkeiten (VVT) zu dokumentieren (Art. 30 DSGVO)
  • Bei hohem Risiko: Datenschutz-Folgenabschätzung (DSFA/DPIA) gem. Art. 35 DSGVO erforderlich

Praktische Konsequenz für das System: Alle personenbezogenen Gesundheitsdaten dürfen den Self-Hosted-Perimeter niemals verlassen. Jeder externe Service (Cloud-PDF-Renderer, CDN, externe Template-Stores) ist unzulässig, solange kein Auftragsverarbeitungsvertrag (AVV) vorliegt und keine Rechtsgrundlage nach Art. 9 Abs. 2 gegeben ist.


2. Architekturübersicht

Komponentendiagramm (ASCII)

┌─────────────────────────────────────────────────────────────────────┐
│                        Ubuntu Server 24.04 LTS                      │
│                        Docker Engine 27.x                           │
│                                                                     │
│  ┌──────────────────┐    ┌─────────────────────────────────────┐   │
│  │   Reverse Proxy  │    │           Docker-Netzwerk           │   │
│  │  (Traefik/Caddy) │    │         serienbrief_net             │   │
│  │  Port 443 (TLS)  │    │                                     │   │
│  └────────┬─────────┘    │  ┌────────────┐  ┌──────────────┐  │   │
│           │              │  │  Directus  │  │  Serienbrief │  │   │
│           │  /api/sb/*   │  │  (exist.)  │  │  Backend     │  │   │
│           ├──────────────┼─▶│  :8055     │  │  (Node.js)   │  │   │
│           │              │  │            │◀─│  :3001       │  │   │
│           │  /sb/*       │  └─────┬──────┘  └──────┬───────┘  │   │
│           ├──────────────┼────────┼─────────────────┼──────────┤   │
│           │              │        │                 │          │   │
│           │              │  ┌─────▼──────┐  ┌──────▼───────┐  │   │
│           │  (SPA)       │  │ PostgreSQL  │  │  Gotenberg/  │  │   │
│           │              │  │ (exist.)   │  │  Carbone     │  │   │
│           │              │  │  :5432     │  │  :3002       │  │   │
│           │              │  └────────────┘  └──────────────┘  │   │
│           │              │                                     │   │
│           │              │  ┌──────────────┐  ┌────────────┐  │   │
│           │              │  │  Template-   │  │  Audit-DB  │  │   │
│           │              │  │  Store Vol.  │  │ (Postgres  │  │   │
│           │              │  │  (bind/vol)  │  │  Schema)   │  │   │
│           │              │  └──────────────┘  └────────────┘  │   │
│           │              └─────────────────────────────────────┘   │
│           │                                                         │
│  ┌────────▼──────────┐                                             │
│  │   Browser-Client  │                                             │
│  │   (Intranet only) │                                             │
│  └───────────────────┘                                             │
└─────────────────────────────────────────────────────────────────────┘

Zusammenspiel der Komponenten

Komponente Rolle Kommunikation
Directus Adressdatenbank, Auth-Provider (RBAC), ggf. Template-Storage REST/GraphQL, intern
Serienbrief-Backend Orchestrierung: Daten holen, Template mergen, PDF-Job triggern intern → Directus API, → PDF-Renderer
PDF-Renderer Konvertierung DOCX/ODT/HTML → PDF (stateless, keine Datenpersistenz) intern, Job-basiert
Template-Store Docker Volume / Directus-Files für Vorlagenverwaltung Datei-Mount
Reverse Proxy TLS-Terminierung, Rate-Limiting, Auth-Header-Forwarding extern → intern
Audit-DB Persistenter Audit-Trail (wer, was, wann) Backend → PostgreSQL-Schema

3. Stack-Vergleich: Option A vs. Option B

Option A: Leichtgewichtig — Carbone.js + Node.js Backend + Vue/React SPA

Beschreibung

Carbone.js ist ein Open-Source-Report-Generator (AGPL-3.0 Community, kommerzielle Lizenz verfügbar), der JSON-Daten in ODT/DOCX-Templates injiziert und via LibreOffice-Integration nach PDF konvertiert. Das Backend ist ein schlanker Express.js-Service; das Frontend eine einfache SPA (Vue 3 oder plain HTML).

Architektur-Sketch

Vue SPA  ──POST /render──▶  Express.js  ──REST──▶  Directus API
                              │
                              ├──carbone.render()──▶  LibreOffice (lokal im Container)
                              │                          │ PDF
                              └──────────────────────────▼──────▶  HTTP-Response (Download)

Vor- und Nachteile

Kriterium Bewertung
Komplexität Gering ein Node.js-Container, kein externer Renderer-Dienst
Performance ~50 ms/PDF bei warmem LibreOffice-Worker; kalt ~35 s
Template-Format ODT/DOCX mit Mustache-ähnlicher Syntax ({d.feldname}), vertraut für Office-User
Lizenz ⚠️ AGPL-3.0 für Community-Edition — bei interner Nutzung unkritisch; bei SaaS-Weiterverteilung Lizenzprüfung nötig
Wartbarkeit Kleiner Footprint, gut testbar, wenig externe Abhängigkeiten
DSGVO-Relevanz Vollständig self-hosted; kein Datentransfer nach außen
Overhead ⚠️ LibreOffice im Container: Image-Größe ~1,52 GB; RAM-Bedarf ~600 MB idle
Frontend-Aufwand ⚠️ Custom SPA muss selbst entwickelt/gepflegt werden
Skalierung ⚠️ LibreOffice-Worker ist prozessbasiert; parallele Requests erfordern mehrere Worker oder Queue

Lizenz-Klarstellung Carbone

Die On-Premise Docker Edition ist frei nutzbar (Community-Features). Kostenpflichtige Lizenzen werden nur für Enterprise-Features (erweitertes Template-Management, API-Rate-Limits, Support) benötigt. Für ein internes Serienbriefwerkzeug reicht die Community-Edition vollständig aus.


Option B: Vollwertig — Gotenberg + Budibase/Appsmith als Low-Code-Frontend

Beschreibung

Gotenberg ist ein containerisierter PDF-Konverter-Dienst (MIT-Lizenz), der über eine REST-API DOCX-, HTML- und andere Formate via LibreOffice oder Headless Chromium in PDF umwandelt. Als Frontend wird Budibase (Self-Hosted, GPL-3.0/Budibase-Lizenz) oder Appsmith (Apache 2.0, Self-Hosted) eingesetzt — beides Low-Code-Builder mit Directus-Konnektoren.

Architektur-Sketch

Budibase UI  ──Query Builder──▶  Directus API  (Daten holen)
     │
     ├──Custom Automation──▶  Serienbrief-Backend  (leichter Glue-Service)
                                     │
                                     ├──DOCX template + JSON data──▶  Gotenberg REST API
                                     │                                      │ PDF
                                     └──────────────────────────────────────▼──▶  Response

Vor- und Nachteile

Kriterium Bewertung
Komplexität ⚠️ Höher: Budibase + Gotenberg + ggf. Backend-Glue = 3+ zusätzliche Container
Frontend-Aufwand Gering Low-Code-Builder; kein Frontend-Entwickler nötig
Template-Format ⚠️ Gotenberg akzeptiert DOCX (via LibreOffice) oder HTML (via Chromium); Template-Variablen-Handling nicht nativ
Gotenberg-Performance LibreOffice-Variante: vergleichbar mit Carbone; Chromium-Variante: höherer RAM-Bedarf
Lizenz Gotenberg MIT keine Einschränkungen
Lizenz Budibase ⚠️ Budibase v2+ nutzt eine eigene "Budibase Personal License" für Self-Hosted; GPL nur für ältere Versionen; Enterprise-Features kostenpflichtig
Lizenz Appsmith Apache 2.0 für Community-Edition
DSGVO-Relevanz Vollständig self-hosted möglich; Budibase/Appsmith senden keine Daten nach außen (Telemetrie deaktivierbar)
Wartbarkeit ⚠️ Budibase/Appsmith sind komplexe Eigenanwendungen mit eigenem Lifecycle
Resource-Footprint ⚠️ Budibase benötigt eigene DB + Backend-Services; Appsmith ebenso
Skalierung Gotenberg skaliert horizontal einfach

Kritische Anmerkung zu Budibase-Lizenz

Ab Budibase v2 gilt eine proprietäre Self-Hosted-Lizenz für kostenfreie Tier-Nutzung. Bei Änderungen am Source-Code entsteht eine Pflicht zur Veröffentlichung nur für AGPL-Teile. Für reine interne Nutzung ohne Modifikation ist das unproblematisch dennoch Lizenztext vor Deployment genau lesen.


Direktvergleich

Kriterium Option A (Carbone + Node.js) Option B (Gotenberg + Budibase)
Entwicklungsaufwand initial Mittel (Custom Backend + SPA) Niedrig (Low-Code)
Langfristiger Pflegeaufwand Niedrig MittelHoch
Container-Anzahl 3 (Backend + LibreOffice-intern + Proxy) 57
RAM-Bedarf (idle) ~700 MB ~1,52,5 GB
Template-Komplexität Mittel (Mustache-Syntax in ODT/DOCX) NiedrigMittel
DSGVO-Risiko Sehr gering Gering (mit Konfiguration)
Audit-Trail Muss selbst implementiert werden Budibase hat einfaches Logging
Lizenz-Risiko Gering (AGPL intern) Mittel (Budibase-Lizenz)
Empfehlung für Gesundheitssektor Bevorzugt Bedingt geeignet

4. Datenfluss im Detail

Schritt-für-Schritt

1. AUTHENTIFIZIERUNG
   Browser → Reverse Proxy (TLS) → Serienbrief-Backend
   Backend validiert Directus-JWT oder statisches API-Token
   → Directus /auth/login oder Bearer-Token-Validierung

2. EMPFÄNGER-SELEKTION
   Frontend → GET /api/sb/recipients?filter=...
   Backend → Directus REST: GET /items/{collection}?filter[status][_eq]=aktiv&fields=id,vorname,nachname,adresse,...
   Directus prüft RBAC (Policy: nur erlaubte Felder zurückgeben)
   Backend → Response: Array von Kontakten (minimiert auf benötigte Felder)

3. TEMPLATE-AUSWAHL
   Frontend → GET /api/sb/templates
   Backend → liest Template-Verzeichnis (Volume-Mount) oder Directus /files?folder=templates
   Response: Liste verfügbarer Vorlagen mit Metadaten

4. RENDER-JOB
   Frontend → POST /api/sb/render
   {
     "templateId": "anschreiben_v3.odt",
     "recipients": [42, 87, 133],       ← Directus-IDs
     "extraFields": {"betreff": "..."}
   }

5. DATEN-ANREICHERUNG
   Backend → Directus: GET /items/{collection}/{id} für jeden Empfänger
   → Merge: Template-Platzhalter ↔ Kontaktfelder
   → Sanitize: HTML-Encoding, keine Script-Injection möglich

6. RENDER
   Backend → Carbone.render(templateBuffer, dataObject, {convertTo: 'pdf'})
   oder:
   Backend → POST http://gotenberg:3000/forms/libreoffice/convert
     multipart: template.docx + data (via DOCX-Variablen vormerged)

7. AUDIT-LOG (synchron, vor Response)
   Backend → INSERT INTO audit_log(user_id, template_id, recipient_count, timestamp, ip)

8. RESPONSE
   Backend → HTTP 200, Content-Type: application/pdf
   Browser → Download-Dialog oder In-Browser-Preview

9. DATENBEREINIGUNG
   Temporäre Dateien im Container: sofortige Löschung nach Response
   Kein Persistieren von generierten PDFs (nur bei explizitem Speicher-Feature)

Sequenzdiagramm (vereinfacht)

Browser        Backend         Directus        PDF-Renderer    Audit-DB
  │                │               │                │              │
  ├─POST /render──▶│               │                │              │
  │                ├─GET /items───▶│                │              │
  │                │◀─JSON data────┤                │              │
  │                ├─render(tmpl,data)──────────────▶│              │
  │                │◀──────────────────────PDF bytes─┤              │
  │                ├─INSERT audit──────────────────────────────────▶│
  │◀─PDF response──┤               │                │              │

5. Template-Management

Anforderungen

  • Templates als ODT/DOCX (LibreOffice-editierbar)
  • Versionierung
  • Zugriffssteuerung: nicht jeder darf Templates bearbeiten
  • Einfaches UI zum Upload ohne Entwickler-Eingriff

Option 5A: Directus als Template-Store (empfohlen)

Directus hat ein eingebautes File-Management mit:

  • Upload-UI
  • Folder-Struktur
  • Metadaten-Felder (Version, Beschreibung, Ersteller, Datum)
  • RBAC: eigene Rolle template_admin mit Schreibrecht auf den /files-Endpoint
Directus Collection: sb_templates
├── id (UUID)
├── name (String)
├── description (String)
├── version (String, z.B. "2.1")
├── file_id (FK → directus_files)
├── active (Boolean)
├── created_by (FK → directus_users)
└── created_at (DateTime)

Das Backend lädt Templates via GET /files/{file_id} und cached sie lokal (Volume-Mount oder In-Memory-Cache mit kurzer TTL).

Vorteil: Single Source of Truth; Versionierung über Directus-Revisionen; keine zusätzliche Infrastruktur.

Option 5B: Dediziertes Volume mit Versionierungs-Convention

Einfacher für pure Admins ohne Directus-Wissen:

/data/serienbrief/templates/
├── anschreiben/
│   ├── anschreiben_v1.0.odt
│   ├── anschreiben_v1.1.odt  ← symlink: anschreiben_current.odt
│   └── anschreiben_v2.0.odt
├── info_brief/
│   ├── ...
└── metadata.json             ← Name, aktive Version, Beschreibung

Das Backend liest metadata.json und exponiert eine Template-Liste. Deployment neuer Templates via scp oder Git-Webhook.

Nachteil: Kein GUI-Upload; kein integriertes RBAC.

Empfehlung: Option 5A (Directus als Template-Store)

Da Directus bereits läuft, ergibt sich kein Mehraufwand. Die RBAC-Integration verhindert, dass unberechtigte Nutzer Templates überschreiben kritisch im regulierten Umfeld.


6. PDF-Rendering: Optionsvergleich

Renderer Technik Docker-Image RAM-Bedarf Schrift-Support DSGVO-OK Empfehlung
LibreOffice via Carbone LibreOffice headless carbone-env-docker (~1,8 GB) ~400600 MB idle Systemfonts + TTF-Install Empfohlen für DOCX/ODT
Gotenberg (LibreOffice-Modul) LibreOffice headless gotenberg/gotenberg:8 (~1,5 GB) ~400800 MB Gut, REST-First-Design
Gotenberg (Chromium-Modul) Headless Chromium gotenberg/gotenberg:8 (~2,5 GB) ~11,5 GB (Web-Fonts nur wenn lokal) ⚠️ Für HTML-Templates sinnvoll, aber schwerer
WeasyPrint Python, CSS-basiertes PDF aus HTML python:3.12-slim + apt ~150300 MB ⚠️ Nur installierte Systemfonts ⚠️ Gut für HTML-Templates; DOCX nicht möglich
Puppeteer/headless Chrome Node.js + Chromium ~1,5 GB ~8001.200 MB ⚠️ Komplex (seccomp!), für interne Lösung Overkill
wkhtmltopdf Qt WebKit veraltete Engine ~200 MB ⚠️ Nicht empfohlen (veraltet, keine aktive Entwicklung)

Sicherheitshinweis: Puppeteer/Chromium in Docker

Headless Chrome benötigt privilegierte Capabilities oder ein angepasstes seccomp-Profil. Der häufig verwendete --no-sandbox-Flag ist in einer produktionsnahen Umgebung mit nicht-vertrauenswürdigen Templates inakzeptabel. Korrekte Konfiguration:

# In docker-compose.yml für Puppeteer-Container
security_opt:
  - seccomp:./chrome-seccomp.json   # Chromium-spezifisches seccomp-Profil
cap_drop:
  - ALL
cap_add:
  - SYS_ADMIN   # Nur wenn kein seccomp-Profil; besser: SUID-Sandbox konfigurieren

Für Gotenberg entfällt dieses Problem, da Gotenberg intern diese Härtung bereits vornimmt und eine sauber definierte API-Grenze bietet.

Empfehlung PDF-Renderer

Gotenberg v8 (LibreOffice-Modul) oder Carbone mit integriertem LibreOffice für DOCX/ODT-Templates. Begründung:

  • MIT-Lizenz, aktiv maintained (Stand 2025)
  • Sauber entkoppelte REST-API; kein direktes LibreOffice-Prozess-Management im Backend nötig
  • Keine --no-sandbox-Probleme
  • Unterstützt DOCX, ODT, XLSX, PPTX → Flexibilität für künftige Erweiterungen

7. Security und DSGVO-Compliance

7.1 Architekturelle Datenschutz-Maßnahmen

Datenminimierung (Art. 5 Abs. 1 lit. c DSGVO)

Das Backend darf von Directus nur die Felder abfragen, die das Template tatsächlich benötigt. Implementierung:

// Schlechte Praxis:
const contact = await directus.get(`/items/contacts/${id}`);

// Korrekte Praxis (Directus field projection):
const contact = await directus.get(
  `/items/contacts/${id}?fields=id,vorname,nachname,adresse,plz,ort`
);

Felder-Allowlist pro Template in der Template-Metadaten-Collection speichern.

Keine Persistenz von generierten PDFs

Generierte PDFs enthalten Gesundheitsdaten. Sie werden nie persistiert, sondern nur im RAM gehalten und direkt als HTTP-Response gestreamt:

// Kein fs.writeFileSync() für finale PDFs
// Stattdessen:
carbone.render(template, data, {convertTo: 'pdf'}, (err, pdfBuffer) => {
  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', 'attachment; filename="serienbrief.pdf"');
  res.send(pdfBuffer);
  // Buffer wird vom GC freigegeben
});

Netzwerkisolation

Alle Services kommunizieren ausschließlich im internen Docker-Netzwerk. Nur der Reverse Proxy ist von außen erreichbar:

networks:
  serienbrief_net:
    internal: true      # Kein direkter Internet-Zugang für Backend/Renderer
  proxy_net:
    driver: bridge      # Nur Proxy hat Zugang nach außen

7.2 Authentifizierung und Autorisierung

Directus RBAC

Für das Serienbrief-System wird eine dedizierte Directus-Policy angelegt:

Rolle Erlaubte Operationen
sb_user Lesen: freigegebene Collections (kein Zugriff auf raw Gesundheitsfelder)
sb_admin Lesen + Schreiben auf sb_templates; Einsehen von sb_audit_log
sb_service Service-Account für das Backend; nur Lese-Zugriff auf Kontakt-Collections

Das Backend-Service-Token wird als Docker Secret verwaltet (nie in .env-Dateien einchecken):

secrets:
  directus_token:
    file: ./secrets/directus_service_token.txt

services:
  serienbrief-backend:
    secrets:
      - directus_token
    environment:
      DIRECTUS_TOKEN_FILE: /run/secrets/directus_token

Session-Management im Frontend

Wenn eine Custom-SPA eingesetzt wird: keine persistente Token-Speicherung in localStorage. Stattdessen sessionStorage oder HttpOnly-Cookie mit kurzem TTL (max. 4 Stunden für regulierten Kontext).

7.3 Audit-Log

Ein Audit-Log ist für den Gesundheitssektor nicht optional. Jede Serienbrief-Generierung erzeugt einen unveränderlichen Log-Eintrag:

CREATE TABLE sb_audit_log (
  id          BIGSERIAL PRIMARY KEY,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  user_id     TEXT NOT NULL,          -- Directus-User-UUID
  user_email  TEXT NOT NULL,          -- Snapshot, da User gelöscht werden können
  template_id TEXT NOT NULL,
  template_version TEXT,
  recipient_count INTEGER NOT NULL,
  recipient_ids TEXT[],               -- Arrays: welche Kontakt-IDs betroffen
  ip_address  INET,
  user_agent  TEXT,
  action      TEXT NOT NULL DEFAULT 'render_pdf',
  -- Keine Nutzdaten (kein PDF-Inhalt, keine personenbezogenen Felder)
  CONSTRAINT no_update CHECK (true)   -- Ergänzt durch DB-Grants: kein UPDATE/DELETE
);

-- Nur INSERT erlaubt für den Service-Account
REVOKE UPDATE, DELETE ON sb_audit_log FROM sb_service_user;

Wichtig: Der Audit-Log selbst enthält recipient_ids (Directus-IDs), aber keine personenbezogenen Felder (kein Name, keine Adresse). Die Verbindung zur Person ist nur über Directus herstellbar und bleibt so auflösbar.

7.4 TLS und Transportverschlüsselung

  • Reverse Proxy terminiert TLS ≥ 1.2 (TLS 1.3 bevorzugen)
  • Interne Container-Kommunikation: im internen Docker-Netzwerk ohne TLS akzeptabel, solange das Host-System gehärtet ist (kein Fremdzugriff auf Docker-Socket)
  • Volumes mit sensiblen Daten (Template-Store): LUKS-verschlüsseltes Dateisystem auf Host-Ebene empfohlen

7.5 Härtungsmaßnahmen für Container

# Best-Practice-Snippet für alle Serienbrief-Services
services:
  serienbrief-backend:
    image: serienbrief-backend:1.0.0
    user: "1001:1001"                 # Non-root
    read_only: true                   # Read-only Rootfs
    tmpfs:
      - /tmp:size=256m,mode=1777      # Schreibbarer Temp-Bereich
    security_opt:
      - no-new-privileges:true
      - seccomp:./seccomp/default.json
    cap_drop:
      - ALL
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
      interval: 30s
      timeout: 5s
      retries: 3

7.6 DSGVO-Checkliste für das Deployment

Anforderung Maßnahme Status
Art. 9 Gesundheitsdaten Schutz Kein Datentransfer nach außen; self-hosted ☐ Vor Go-Live prüfen
Art. 30 VVT Serienbrief-System als neue Verarbeitungstätigkeit dokumentieren ☐ DSB/Admin
Art. 32 TOMs Verschlüsselung (TLS, LUKS), Zugriffssteuerung, Audit-Log ☐ Vor Go-Live
Art. 35 DSFA Bei hohem Risiko (Massenverarbeitung Gesundheitsdaten): DPIA durchführen ☐ DSB-Entscheidung
Art. 17 Löschrecht Löschkonzept für generierte PDFs (keine Persistenz = automatisch erfüllt) ☐ Dokumentieren
Berufsgeheimnis (§ 54 ÄrzteG AT) Zugriff nur für autorisiertes Personal; RBAC ☐ Rollen definieren

8. Docker Compose Grundstruktur

Die folgende Struktur ist als Ausgangspunkt konzipiert nicht als vollständige Produktionskonfiguration. Environment-spezifische Werte (Passwörter, Tokens) gehören in .env-Dateien, die nicht ins Git-Repository eingecheckt werden.

/opt/serienbrief/
├── docker-compose.yml
├── docker-compose.override.yml     # Lokale Dev-Overrides
├── .env                            # Nicht ins Repo!
├── secrets/
│   ├── directus_service_token.txt
│   └── db_password.txt
├── config/
│   ├── traefik/
│   │   └── traefik.yml
│   └── seccomp/
│       └── default.json
├── templates/                      # Bind-Mount für Template-Store (Option B)
└── Makefile                        # Deployment-Shortcuts
# docker-compose.yml
# Hinweis: Directus und PostgreSQL existieren bereits in einem separaten Compose-Projekt.
# Dieses Compose-File referenziert das externe Netzwerk über `external: true`.

name: serienbrief

networks:
  serienbrief_int:
    driver: bridge
    internal: true           # Kein Internet-Zugang für interne Services
  proxy_net:
    external: true           # Bestehendes Proxy-Netzwerk (Traefik/Caddy)
  directus_net:
    external: true           # Bestehendes Directus-Netzwerk

secrets:
  directus_token:
    file: ./secrets/directus_service_token.txt
  db_audit_password:
    file: ./secrets/db_audit_password.txt

volumes:
  sb_templates:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: /opt/serienbrief/templates
  sb_audit_db_data:

services:

  # ─────────────────────────────────────────────
  # Serienbrief-Backend (Node.js / Express)
  # ─────────────────────────────────────────────
  serienbrief-backend:
    image: ghcr.io/meine-org/serienbrief-backend:${SB_VERSION:-latest}
    restart: unless-stopped
    user: "1001:1001"
    read_only: true
    tmpfs:
      - /tmp:size=256m,mode=1777
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    secrets:
      - directus_token
      - db_audit_password
    environment:
      NODE_ENV: production
      DIRECTUS_URL: http://directus:8055       # internes Netzwerk
      DIRECTUS_TOKEN_FILE: /run/secrets/directus_token
      PDF_RENDERER_URL: http://gotenberg:3000  # oder carbone-service
      AUDIT_DB_HOST: sb-audit-db
      AUDIT_DB_NAME: sb_audit
      AUDIT_DB_USER: sb_audit_user
      AUDIT_DB_PASSWORD_FILE: /run/secrets/db_audit_password
      TEMPLATE_STORE_PATH: /templates
      LOG_LEVEL: info
    volumes:
      - sb_templates:/templates:ro             # Templates read-only
    networks:
      - serienbrief_int
      - directus_net
      - proxy_net
    labels:
      # Traefik-Labels (anpassen an eigene Traefik-Version)
      - "traefik.enable=true"
      - "traefik.http.routers.sb-backend.rule=Host(`intern.example.at`) && PathPrefix(`/api/sb`)"
      - "traefik.http.routers.sb-backend.tls=true"
      - "traefik.http.services.sb-backend.loadbalancer.server.port=3001"
      # Rate-Limiting Middleware (konfiguriert in Traefik)
      - "traefik.http.routers.sb-backend.middlewares=ratelimit-sb@file"
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
      interval: 30s
      timeout: 5s
      retries: 3

  # ─────────────────────────────────────────────
  # PDF-Renderer: Gotenberg v8 (LibreOffice-Modul)
  # ─────────────────────────────────────────────
  gotenberg:
    image: gotenberg/gotenberg:8
    restart: unless-stopped
    # Gotenberg läuft intern als non-root
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    command:
      - "gotenberg"
      - "--api-timeout=30s"
      - "--libreoffice-restart-after=100"     # Verhindert Memory-Leaks bei vielen Konvertierungen
      - "--log-level=info"
      - "--log-format=json"
      # Chromium deaktivieren (nicht benötigt, reduziert Angriffsfläche)
      - "--chromium-disable-routes=true"
    networks:
      - serienbrief_int
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: "2.0"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # ─────────────────────────────────────────────
  # Audit-Datenbank (dedizierte PostgreSQL-Instanz)
  # ─────────────────────────────────────────────
  sb-audit-db:
    image: postgres:16-alpine
    restart: unless-stopped
    user: "70:70"                              # postgres user in alpine image
    read_only: true
    tmpfs:
      - /var/run/postgresql:uid=70,gid=70
      - /tmp:uid=70,gid=70
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    environment:
      POSTGRES_DB: sb_audit
      POSTGRES_USER: sb_audit_user
      POSTGRES_PASSWORD_FILE: /run/secrets/db_audit_password
    secrets:
      - db_audit_password
    volumes:
      - sb_audit_db_data:/var/lib/postgresql/data
    networks:
      - serienbrief_int
    command:
      - postgres
      - -c
      - log_connections=on
      - -c
      - log_disconnections=on
      - -c
      - log_statement=ddl                      # DDL loggen; DML über Audit-Trigger
      - -c
      - max_connections=20                     # Serienbrief-Backend nutzt wenige Connections
    deploy:
      resources:
        limits:
          memory: 256M
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U sb_audit_user -d sb_audit"]
      interval: 30s
      timeout: 5s
      retries: 5

  # ─────────────────────────────────────────────
  # (Optional) Serienbrief-Frontend (Static SPA)
  # ─────────────────────────────────────────────
  serienbrief-frontend:
    image: nginx:1.27-alpine
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /var/cache/nginx:uid=101
      - /var/run:uid=101
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE                       # Port 80 als non-root
    volumes:
      - ./frontend/dist:/usr/share/nginx/html:ro
      - ./config/nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - proxy_net
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.sb-frontend.rule=Host(`intern.example.at`) && PathPrefix(`/sb`)"
      - "traefik.http.routers.sb-frontend.tls=true"
    deploy:
      resources:
        limits:
          memory: 64M
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:80/"]
      interval: 30s
      timeout: 5s
      retries: 3

Makefile-Hilfsbefehle

# /opt/serienbrief/Makefile

.PHONY: up down logs audit-tail update-templates health

up:
    docker compose pull && docker compose up -d

down:
    docker compose down --remove-orphans

logs:
    docker compose logs -f --tail=100

audit-tail:
    docker compose exec sb-audit-db psql -U sb_audit_user -d sb_audit \
      -c "SELECT created_at, user_email, template_id, recipient_count, ip_address \
          FROM sb_audit_log ORDER BY created_at DESC LIMIT 50;"

# Template-Deployment ohne Container-Neustart
update-templates:
    rsync -av --checksum ./templates/ /opt/serienbrief/templates/
    docker compose exec serienbrief-backend \
      wget -qO- http://localhost:3001/admin/reload-templates

health:
    docker compose ps
    docker compose exec serienbrief-backend wget -qO- http://localhost:3001/health
    docker compose exec gotenberg curl -s http://localhost:3000/health

9. Empfehlung und Entscheidungsbegründung

Empfohlener Stack

Option A mit Gotenberg als eigenständigem Renderer

Carbone.js (Template-Engine, Mustache-Syntax)
  +
Gotenberg v8  LibreOffice-Modul (REST-PDF-Renderer, MIT-Lizenz)
  +
Node.js/Express Backend (Orchestrierung, Audit-Log)
  +
Directus als Template-Store + Adressdatenbank (bereits vorhanden)
  +
Schlanke Vue 3 SPA oder Directus-eigene Flows für UI

Begründung

1. Kontrolle und Transparenz über die gesamte Pipeline

Als erfahrener Admin und Data Protection Officer ist die vollständige Nachvollziehbarkeit des Datenflusses entscheidend. Eine Low-Code-Plattform wie Budibase abstrahiert Interna, die im regulierten Umfeld dokumentiert werden müssen. Ein eigenes, überschaubares Node.js-Backend (~300500 LoC) ist vollständig auditierbar.

2. DSGVO-Konformität by Design

  • Kein Datentransfer nach außen; alle Komponenten self-hosted
  • Kein localStorage für Tokens; kein Telemetrie-Risiko
  • Audit-Log ist fester Bestandteil, nicht nachgerüstet
  • Datenfeldminimierung ist im Backend-Code erzwingbar; bei Low-Code-Lösungen oft umgehbar

3. Lizenzrisiko ist beherrschbar

Carbone AGPL-3.0 ist für interne, nicht weiterverteilte Nutzung unproblematisch. Gotenberg MIT hat keinerlei Einschränkungen. Eine Budibase-Proprietär-Lizenz birgt mehr langfristiges Risiko.

4. Ressourceneffizienz

Der vorgeschlagene Stack benötigt ~1,52 GB RAM. Option B mit Budibase würde allein für die Plattform ~12 GB zusätzlich beanspruchen auf einem dedizierten Ubuntu-Server im Gesundheitssektor mit definierten Ressourcen kein unwesentlicher Faktor.

5. Wartbarkeit für einen Admin ohne Frontend-Entwickler

Das Backend ist mit Standard-Node.js-Werkzeugen wartbar. Gotenberg-Updates sind trivial (docker pull). Templates werden über Directus verwaltet die Nutzer kennen diese Oberfläche bereits.

6. Upgrade-Pfad ist klar

Sollte das System wachsen (mehr parallele Nutzer, größere Batches), kann:

  • Gotenberg horizontal skaliert werden (mehrere Replicas im Swarm)
  • Eine Job-Queue (Bull/BullMQ mit Redis) vor den PDF-Renderer geschaltet werden
  • Das Frontend durch Budibase/Appsmith ersetzt werden, ohne Backend-Änderungen

Entscheidungsbaum

Hast du Frontend-Entwickler verfügbar?
├── Nein ──▶ Directus Flows + Backend-API (minimales Frontend reicht)
│           oder Appsmith (Apache 2.0, weniger Lizenzrisiko als Budibase)
└── Ja   ──▶ Vue 3 SPA (vollständige Kontrolle, DSGVO-sauber)

Werden mehr als 50 PDFs/Stunde generiert?
├── Nein ──▶ Carbone mit integriertem LibreOffice (einfachste Option)
└── Ja   ──▶ Gotenberg als dedizierter Service + Job-Queue

Sollen Templates von Nicht-Technikern erstellt werden?
├── Nein ──▶ ODT/DOCX-Dateien im Directus-File-Manager
└── Ja   ──▶ Directus-Collection mit Upload-UI + Validierung

Nicht empfohlen (mit Begründung)

Option Grund
Cloud-basierter PDF-Service (DocuSign, Adobe, Cloudmersive) Drittlandtransfer von Gesundheitsdaten; DSGVO-konformer AVV meist nicht ausreichend für Art. 9-Daten
Puppeteer/headless Chrome selbst verwaltet Sicherheitskomplexität (seccomp, sandbox); wartungsintensiv; Gotenberg bietet dieselbe Funktionalität mit weniger Aufwand
wkhtmltopdf Keine aktive Entwicklung seit 2020; bekannte Rendering-Schwächen; sicherheitsrelevante Bugs ungepatcht
Budibase als primäre Datenquelle (statt Directus) Würde bestehende Directus-Infrastruktur verdoppeln; Datenduplikation mit DSGVO-Implikationen

Abschlussbewertung

Das vorgeschlagene System hält sich an das Prinzip minimale Komplexität bei maximaler Auditierbarkeit. Für einen Data Protection Officer im Gesundheitssektor ist nicht die technische Eleganz entscheidend, sondern die Fähigkeit, bei einer Prüfung durch die österreichische Datenschutzbehörde (DSB) jeden Schritt der Datenverarbeitung lückenlos erklären und belegen zu können. Ein überschaubares Node.js-Backend mit explizitem Audit-Log und klarer Netzwerkisolation erfüllt diese Anforderung besser als jede Black-Box-Low-Code-Plattform.


Erstellt auf Basis von: Carbone.js GitHub, Gotenberg Dokumentation, Directus Docs, Art. 9 DSGVO, Österreichisches DSG 2018, Sozialministerium AT Schutz sensibler Daten, Puppeteer Troubleshooting