2026-05-21 10:36:16 +02:00
# Serienbrief – DOCX‑ Mailmerge mit Django, Celery & LibreOffice
Web‑ Anwendung zur Erstellung personalisierter Serienbriefe aus DOCX‑ Vorlagen und CSV‑ Empfängerlisten. Ergebnis ist ein zusammengeführtes PDF mit einem Brief pro Empfänger:in.
Zielumgebung: internes LAN, hinter zentralem Reverse‑ Proxy (TLS‑ Terminierung extern). Produktionsnah konzipiert: Hardening, Resource‑ Limits, Healthchecks, Backup‑ Job.
---
## Inhaltsverzeichnis
1. [Funktionsumfang ](#funktionsumfang )
2. [Architekturübersicht ](#architekturübersicht )
3. [Tech‑ Stack ](#techstack )
4. [Komponenten im Detail ](#komponenten-im-detail )
5. [Verzeichnisstruktur ](#verzeichnisstruktur )
6. [Datenfluss eines Serienbrief‑ Jobs ](#datenfluss-eines-serienbriefjobs )
7. [Datenmodell ](#datenmodell )
8. [Sicherheitskonzept ](#sicherheitskonzept )
9. [Netzwerk & Ports ](#netzwerk--ports )
10. [Volumes & Persistenz ](#volumes--persistenz )
11. [Konfiguration ](#konfiguration )
12. [Inbetriebnahme ](#inbetriebnahme )
13. [Entwicklung in VS Code ](#entwicklung-in-vs-code )
14. [Betrieb & Wartung ](#betrieb--wartung )
15. [Datenschutz & Compliance ](#datenschutz--compliance )
16. [Bekannte Einschränkungen & Roadmap ](#bekannte-einschränkungen--roadmap )
---
## Funktionsumfang
- DOCX‑ Vorlagen mit Jinja‑ artigen Platzhaltern (`{{ feldname }}` ) via **docxtpl**
- CSV‑ Upload als Empfängerliste (UTF‑ 8, Komma‑ Trenner)
- Asynchrone Verarbeitung über Celery — UI bleibt responsiv, Status‑ Polling per HTMX
- DOCX → PDF Konversion mit headless **LibreOffice**
- Zusammenführung aller Einzel‑ PDFs via **pypdf**
- Authentifizierung über Djangos Auth‑ System, Brute‑ Force‑ Schutz mit **django‑ axes**
- Geschütztes Ausliefern generierter PDFs via **X‑ Accel‑ Redirect** (Nginx)
- Audit‑ Log pro Job (Status‑ Wechsel, Fehler, abgearbeitete Zeilen)
- Tägliches Backup (pg_dump + Media‑ Tar, 14 Tage Retention)
---
## Architekturübersicht
```
┌──────────────────────────────────────────────┐
│ Externer LAN‑ Proxy (Nginx, TLS‑ Terminierung)│
│ https://serienbrief.lan │
└────────────────────┬─────────────────────────┘
│ HTTP (intern)
▼
┌───────────────────────────────────────────────────────────┐
│ Compose‑ Stack »serienbrief« │
│ │
│ ┌───────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ nginx │──▶│ web (Django) │──▶│ db (Postgres 16) │ │
│ │ App‑ Proxy │ │ Gunicorn │ │ │ │
│ └─────┬─────┘ └──────┬───────┘ └──────────────────┘ │
│ │ X‑ Accel │ enqueue │
│ │ static/media ▼ │
│ │ ┌──────────────┐ ┌──────────────────┐ │
│ └────────▶│ redis (Broker)│◀─│ worker (Celery) │ │
│ └──────────────┘ │ LibreOffice+pypdf│ │
│ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ beat (Cron) │ │ backup (pg_dump) │ │
│ └──────────────┘ └──────────────────┘ │
└───────────────────────────────────────────────────────────┘
```
Zwei Bridge‑ Netze trennen Verantwortlichkeiten:
- **frontend** – nur `nginx` ↔ `web` . Einziges Netz mit Port‑ Bind nach außen.
- **backend** – `web` , `worker` , `beat` , `db` , `redis` , `backup` . Kein direktes Internet‑ /LAN‑ Exposing.
---
## Tech‑ Stack
| Schicht | Komponente | Version |
|---|---|---|
| Host‑ OS | Ubuntu Server LTS | 22.04 / 24.04 |
| Container Runtime | Docker Engine | 27.x+ |
| Orchestrierung | Docker Compose v2 | — |
| App‑ Framework | Django | 5.1.4 |
| WSGI (Prod) | Gunicorn | — |
| Task Queue | Celery | 5.4 |
| Broker / Result Backend | Redis | 7‑ alpine |
| Datenbank | PostgreSQL | 16‑ alpine |
| DOCX‑ Templating | docxtpl | 0.19 |
| DOCX→PDF | LibreOffice (headless) | bundled in Image |
| PDF‑ Merge | pypdf | 5.1 |
| App‑ Proxy | Nginx | 1.27‑ alpine |
| Auth‑ Hardening | django‑ axes | aktuell |
| UI‑ Interaktion | django‑ htmx | aktuell |
| DB‑ Adapter | psycopg | 3.2.3 (binary) |
| Python | CPython | 3.12 (slim‑ bookworm) |
---
## Komponenten im Detail
### `nginx` – App‑ interner Reverse‑ Proxy
- Terminiert **kein** TLS (übernimmt der externe Proxy)
- Liefert Static‑ und Media‑ Dateien direkt aus den Volumes aus
- Setzt `X‑ Accel‑ Redirect` für Auth‑ geschützte PDFs um: Django prüft die Berechtigung, Nginx streamt die Datei ohne Python im Pfad
- Hört auf `${APP_BIND_IP:-127.0.0.1}:${APP_BIND_PORT:-8080}` (Default Loopback)
- Healthcheck gegen `/healthz`
### `web` – Django + Gunicorn
- Rolle `web` (per `ROLE` ‑ Env unterschieden, gleiches Image wie worker/beat)
- Stateless, read‑ only Root‑ FS, `/tmp` als tmpfs
- Settings‑ Split: `base.py` , `dev.py` , `production.py`
- Entrypoint wartet auf DB‑ Readiness, führt `migrate` und (in Prod) `collectstatic` aus
- Healthcheck via `/healthz`
### `worker` – Celery
- Verarbeitet `process_mailmerge_job` ‑ Tasks
- LibreOffice‑ User‑ Profil im tmpfs (`/tmp/lo_profile_<task_id>` ) → keine Leaks zwischen Jobs
- `--concurrency=2` , `--max-tasks-per-child=50` (RAM‑ Leak‑ Schutz bei LibreOffice)
### `beat` – Celery Scheduler
- Nutzt `DatabaseScheduler` von django‑ celery‑ beat
- Geplant für: Retention‑ Cleanup (alte Jobs/PDFs), Audit‑ Log‑ Rotation
- *Hinweis: Konkrete Periodic Tasks aktuell noch nicht registriert (siehe Roadmap)*
### `db` – PostgreSQL 16
- Eigenes Volume `postgres_data`
- Passwort via Docker‑ Secret (`/run/secrets/postgres_password` )
- Init‑ Skripte unter `./postgres/init` (z.B. zusätzliche Extensions)
- `shm_size: 256mb` für komplexere Queries
### `redis` – Broker & Result Backend
- Passwortgeschützt (`REDIS_PASSWORD` aus `.env` )
- AOF persistence + RDB snapshot (`save 900 1` )
- `maxmemory 256mb` mit `allkeys‑ lru`
### `backup` – nightly pg_dump + media tar
- Custom‑ Loop mit `sleep 86400` (kein systemd nötig)
- pg_dump im Custom‑ Format (`-Fc` ) → `pg_restore` ‑ kompatibel
- Media‑ Tar aus `media_files` ‑ Volume (read‑ only Mount)
- Retention 14 Tage (konfigurierbar)
- Schreibt nach `./backups` auf dem Host
---
## Verzeichnisstruktur
```
serienbrief/
├── app/ # Django‑ Projekt
│ ├── Dockerfile # Multi‑ Stage: builder → runtime‑ base → runtime / dev
│ ├── entrypoint.sh # wait_for_db → migrate → collectstatic → exec CMD
│ ├── manage.py
│ ├── requirements.txt
│ ├── requirements-dev.txt # debugpy, ipython, ruff, pytest, debug-toolbar
│ ├── config/ # Project package
│ │ ├── celery.py
│ │ ├── urls.py
│ │ ├── wsgi.py
│ │ └── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ └── production.py
│ ├── mailmerge/ # Hauptapp
│ │ ├── models.py # LetterTemplate, MailMergeJob, JobLogEntry
│ │ ├── views.py
│ │ ├── admin.py
│ │ ├── forms.py
│ │ ├── urls.py
│ │ ├── tasks.py # Celery tasks
│ │ ├── services/
│ │ │ ├── docx_renderer.py # docxtpl + LibreOffice
│ │ │ └── pdf_merge.py # pypdf
│ │ └── management/commands/
│ │ └── wait_for_db.py
│ └── templates/
│ ├── base.html
│ ├── registration/login.html
│ └── mailmerge/…
├── nginx/
│ ├── nginx.conf
│ └── conf.d/serienbrief.conf
├── postgres/init/ # SQL init scripts
├── secrets/
│ └── postgres_password.txt # 0600, NICHT in Git
├── backups/ # nightly dumps + media tars
├── docker-compose.yml # Produktion
├── docker-compose.override.yml # Dev (automatisch gemerged)
├── .env # NICHT in Git
├── .env.example
├── .devcontainer/ # VS Code Dev Container
├── .vscode/ # launch / tasks / settings / extensions
└── README.md
```
---
## Datenfluss eines Serienbrief‑ Jobs
1. **Upload Vorlage:** `LetterTemplate` mit DOCX‑ Datei wird angelegt (Admin oder UI)
2. **Job‑ Erstellung:** User wählt Vorlage + lädt CSV hoch → `MailMergeJob(status=queued)`
3. **Enqueue:** View löst `process_mailmerge_job.delay(job.pk)` aus
4. **Worker holt Task:**
- Liest CSV (UTF‑ 8, validiert Header gegen Platzhalter)
- Iteriert Zeilen → docxtpl rendert pro Zeile eine temporäre DOCX in `/tmp`
- LibreOffice (`soffice --headless --convert-to pdf …` ) erzeugt pro DOCX ein PDF
- pypdf merged alle PDFs in eine Ausgabedatei unter `media_files`
- `JobLogEntry` s pro Schritt; Status `running → done` bzw. `failed`
5. **UI‑ Polling:** HTMX fragt `/jobs/<id>/status/` alle ~2 s ab → Fortschrittsbalken
6. **Download:** Nutzer klickt Download → View prüft Auth → Antwort enthält `X‑ Accel‑ Redirect: /protected/…` → Nginx streamt das PDF
---
## Datenmodell
```text
LetterTemplate
├── id, name (unique), description
├── docx_file (FileField → media/templates/)
├── created_by (FK User), created_at, updated_at
└── is_active
MailMergeJob
├── id, template (FK LetterTemplate)
├── csv_file (FileField → media/jobs/<id>/input.csv)
├── output_pdf (FileField → media/jobs/<id>/output.pdf)
├── status: queued | running | done | failed
├── total_rows, processed_rows
├── created_by, created_at, started_at, finished_at
└── error_message
JobLogEntry
├── id, job (FK MailMergeJob, related_name="log_entries")
├── level: info | warning | error
├── message, created_at
```
---
## Sicherheitskonzept
Im Sinne der Space‑ Vorgabe »Security‑ by‑ Default«:
### Container‑ Hardening
- `no-new-privileges: true` bei allen Services
- `read_only: true` für `web` , `worker` , `beat` (writable nur via tmpfs `/tmp` )
- Non‑ root‑ User `app` (UID/GID 1000, anpassbar per Build‑ Args)
- Minimales Image (`python:3.12-slim-bookworm` ), Multi‑ Stage‑ Build trennt Build‑ und Laufzeit‑ Dependencies
- Ressourcen‑ Limits (memory + cpus) auf allen Services
- `tmpfs` ‑ Größen explizit limitiert (`size=256M` , etc.)
### Netzwerk
- Zwei isolierte Bridges (`frontend` , `backend` )
- Nur `nginx` bindet nach außen, default auf **Loopback**
- DB und Redis ausschließlich auf `backend`
- Externer Proxy übernimmt TLS, HSTS, ggf. IP‑ Allowlist
### Secrets
- Postgres‑ Passwort via Docker‑ Secret (`./secrets/postgres_password.txt` , Mode 0600)
- Redis‑ Passwort + Django‑ Secret‑ Key in `.env` (nicht in Git)
- `.env` und `secrets/` sind in `.gitignore` ausgenommen
### Anwendungs‑ Sicherheit
- django‑ axes: Lockout nach n Fehlversuchen
- CSRF aktiv, Trusted Origins explizit gepflegt
- `SECURE_PROXY_SSL_HEADER` gesetzt, damit Django HTTPS hinter dem externen Proxy korrekt erkennt
- `USE_X_FORWARDED_HOST=True`
- X‑ Accel‑ Redirect statt direktem File‑ Download → Nginx liefert nur, was Django freigibt
- Session‑ Cookies `HttpOnly` , `SameSite=Lax` , `Secure` (in Prod)
### Defense‑ in‑ Depth (Empfehlungen)
- AppArmor‑ Profile pro Container (z.B. via `--security-opt apparmor=...` ) – aktuell nicht aktiv
- seccomp‑ Profile (Default reicht, custom optional)
- Image‑ Scanning (`trivy` , `grype` ) in CI integrieren
- Renovate/Dependabot für Base‑ Image und Python‑ Deps
---
## Netzwerk & Ports
| Port (Host) | Service | Sichtbarkeit | Zweck |
|---|---|---|---|
| 8080 | nginx | `${APP_BIND_IP}` | HTTP für externen Proxy |
| 8000 | web | nur Dev | Django runserver |
| 5678 | web (Dev) | nur Dev | debugpy |
| 5432 | db | nur Dev | Postgres (Tools wie pgAdmin) |
| 6379 | redis | nur Dev | Redis‑ CLI |
In Produktion bleiben **nur** Port 8080 (intern an externen Proxy) erreichbar.
---
## Volumes & Persistenz
| Volume | Inhalt | Backup? |
|---|---|---|
| `postgres_data` | Postgres‑ Daten | pg_dump nightly |
| `redis_data` | AOF + RDB | nein (Broker‑ State, regenerierbar) |
| `static_files` | Generierte Static‑ Files | nein (regenerierbar via `collectstatic` ) |
| `media_files` | Hochgeladene Templates, CSVs, generierte PDFs | tar nightly |
| `nginx_cache` | Nginx Cache | nein |
| `nginx_run` | Nginx PID/Socket | nein |
Bind‑ Mount im Dev: `./app:/app` für Live‑ Reload.
---
## Konfiguration
`.env` (aus `.env.example` abgeleitet) — Auszug:
```env
# App
APP_VERSION = 1.0.0
APP_BIND_IP = 127.0.0.1
APP_BIND_PORT = 8080
APP_UID = 1000
APP_GID = 1000
# Django
DJANGO_SECRET_KEY = <openssl rand -base64 64>
DJANGO_DEBUG = False
DJANGO_ALLOWED_HOSTS = serienbrief.lan,127.0.0.1,localhost
CSRF_TRUSTED_ORIGINS = https://serienbrief.lan
SECURE_PROXY_SSL_HEADER = HTTP_X_FORWARDED_PROTO,https
USE_X_FORWARDED_HOST = True
# Postgres
POSTGRES_DB = serienbrief
POSTGRES_USER = serienbrief
DATABASE_URL = postgres://serienbrief:<HEX-PWD>@db:5432/serienbrief
# Redis
REDIS_PASSWORD = <openssl rand -hex 32>
CELERY_BROKER_URL = redis://:<REDIS_PASSWORD>@redis:6379/0
CELERY_RESULT_BACKEND = redis://:<REDIS_PASSWORD>@redis:6379/1
# Business
JOB_RETENTION_DAYS = 30
```
**Wichtig:**
- `secrets/postgres_password.txt` muss exakt denselben Wert enthalten wie in `DATABASE_URL`
- Datei darf **kein** abschließendes Newline enthalten — Generierung: `openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt && chmod 600 secrets/postgres_password.txt`
- Hex statt Base64, weil Base64‑ `=` URL‑ Parsing in `DATABASE_URL` bricht
---
## Inbetriebnahme
### Voraussetzungen
- Ubuntu 22.04/24.04 LTS, aktueller Patch‑ Stand
- Docker Engine 27.x+ und Compose v2 (apt‑ Pakete `docker-ce` + `docker-compose-plugin` , nicht Snap)
- Host‑ User in `docker` ‑ Gruppe (`sudo usermod -aG docker $USER` , dann Re‑ Login)
### Erststart
```bash
# 1. Repo holen / entpacken in z.B. ~/projekte/serienbrief
cd ~/projekte/serienbrief
# 2. Konfiguration aus Template ableiten
cp .env.example .env
$EDITOR .env # Werte ausfüllen, siehe oben
# 3. Postgres‑ Passwort als Datei
openssl rand -hex 32 | tr -d '\n' > secrets/postgres_password.txt
chmod 600 secrets/postgres_password.txt
# → diesen Wert auch in DATABASE_URL eintragen
# 4. Image bauen
docker compose build
# 5. Hochfahren
docker compose up -d
# 6. Status & Logs
docker compose ps -a
docker compose logs -f web
# 7. Superuser anlegen
docker compose exec web python manage.py createsuperuser
```
2026-05-21 17:54:38 +02:00
### Externen Reverse-Proxy konfigurieren
Der Stack liefert intern HTTP auf `${APP_BIND_IP}:${APP_BIND_PORT}` aus (Default `127.0.0.1:8080` ). Der externe Proxy übernimmt TLS-Terminierung und die nach außen sichtbare Domain.
#### Voraussetzungen für externen Zugriff
Wenn der Proxy auf einem **anderen Host** läuft (typisch bei Nginx Proxy Manager als eigener Container/VM), muss `APP_BIND_IP` von `127.0.0.1` auf eine LAN-erreichbare Adresse umgestellt werden:
```bash
# LAN-IP des Docker-Hosts ermitteln
ip -4 -br a | grep -v lo
# In .env eintragen (Beispiel)
sed -i 's/^APP_BIND_IP=.*/APP_BIND_IP=192.168.10.42/' .env
docker compose up -d
```
Alternativ `0.0.0.0` für alle Interfaces — die explizite IP ist sicherheitstechnisch sauberer.
Firewall absichern, damit nur der Proxy-Host auf 8080 zugreifen darf:
```bash
sudo ufw allow from <proxy-host-ip> to any port 8080 proto tcp comment 'reverse proxy'
sudo ufw deny 8080
```
#### Django-Seite anpassen
Die externe Domain muss in `.env` eingetragen sein, sonst lehnt Django die Requests ab (400 Bad Request bzw. 403 CSRF):
```env
DJANGO_ALLOWED_HOSTS = serienbrief.example.lan,localhost,127.0.0.1
CSRF_TRUSTED_ORIGINS = https://serienbrief.example.lan,http://localhost:8080,http://127.0.0.1:8080
```
Nach Änderung `docker compose restart web` .
#### Variante A: Nginx Proxy Manager (NPM)
Im NPM-Webinterface unter **Proxy Hosts → Add Proxy Host** :
| Feld | Wert |
|---|---|
| Domain Names | `serienbrief.example.lan` |
| Scheme | `http` |
| Forward Hostname / IP | LAN-IP des Docker-Hosts (z.B. `192.168.10.42` ) |
| Forward Port | `8080` |
| Cache Assets | aus |
| Block Common Exploits | an |
| Websockets Support | an (HTMX nutzt nur AJAX, schadet aber nicht) |
Im Tab **Custom Locations** oder **Advanced** zusätzlich:
```nginx
client_max_body_size 50M ;
proxy_set_header X-Forwarded-Proto $scheme ;
proxy_set_header X-Forwarded-Host $host ;
proxy_set_header X-Real-IP $remote_addr ;
proxy_read_timeout 300s ;
proxy_send_timeout 300s ;
```
NPM setzt `Host` , `X-Forwarded-For` und Standard-Header bereits selbst. **Pflicht** ist nur `X-Forwarded-Proto $scheme` , weil Djangos `SECURE_PROXY_SSL_HEADER` darauf basiert.
TLS-Zertifikat (Let's Encrypt für interne Domains via DNS-Challenge, oder eigenes CA-Cert) im Tab **SSL** zuweisen.
#### Variante B: Generisches Nginx / OpenResty
2026-05-21 10:36:16 +02:00
```nginx
server {
listen 443 ssl http2 ;
2026-05-21 17:54:38 +02:00
server_name serienbrief.example.lan ;
2026-05-21 10:36:16 +02:00
ssl_certificate /etc/ssl/lan/serienbrief.crt ;
ssl_certificate_key /etc/ssl/lan/serienbrief.key ;
2026-05-21 17:54:38 +02:00
client_max_body_size 50M ; # für CSV/DOCX-Uploads
2026-05-21 10:36:16 +02:00
location / {
proxy_pass http://<docker-host-ip>:8080 ;
2026-05-21 17:54:38 +02:00
proxy_http_version 1 .1 ;
2026-05-21 10:36:16 +02:00
proxy_set_header Host $host ;
proxy_set_header X-Real-IP $remote_addr ;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for ;
proxy_set_header X-Forwarded-Proto https ;
2026-05-21 17:54:38 +02:00
proxy_set_header X-Forwarded-Host $host ;
proxy_read_timeout 300s ;
proxy_send_timeout 300s ;
2026-05-21 10:36:16 +02:00
proxy_redirect off ;
}
}
```
2026-05-21 17:54:38 +02:00
#### Diagnose bei 502 Bad Gateway
Vom Proxy-Host aus testen:
```bash
# TCP-Connect — "succeeded" erwartet
nc -zv <docker-host-ip> 8080
# HTTP-Request — 200/302 erwartet
curl -v -H "Host: serienbrief.example.lan" http://<docker-host-ip>:8080/
```
Typische Ursachen:
- `Connection refused` → `APP_BIND_IP` steht noch auf `127.0.0.1`
- `Timeout` → Firewall blockt
- 502 ohne Connect-Fehler → falsche Upstream-IP im Proxy-Eintrag
2026-05-21 10:36:16 +02:00
---
## Entwicklung in VS Code
- `.devcontainer/devcontainer.json` referenziert den Compose‑ Stack mit Dev‑ Override
- `.vscode/launch.json` enthält Configs:
- **Django: runserver (attach)** → debugpy auf 5678
- **Django: pytest** → führt Tests im Container aus
- `.vscode/tasks.json` für `makemigrations` , `migrate` , `shell` , `collectstatic`
- `.vscode/extensions.json` empfiehlt: Python, Pylance, Django, Docker, Even Better TOML, Ruff
### Workflow
```bash
# Stack hochfahren (Override greift automatisch)
docker compose up -d
# Code‑Änderungen werden via Bind‑ Mount sofort sichtbar
# runserver reloaded automatisch
# Debugging: F5 in VS Code → attach an debugpy:5678
```
### Wichtige Unterschiede Dev ↔ Prod
| Aspekt | Dev | Prod |
|---|---|---|
| WSGI | `runserver` | Gunicorn |
| Code | Bind‑ Mount `./app:/app` | im Image |
| Healthcheck | deaktiviert | aktiv (`/healthz` ) |
| `read_only` | aus | an |
| `DJANGO_DEBUG` | True | False |
| `collectstatic` | auto im Entrypoint | im Entrypoint |
| `target` | `dev` (mit debugpy, ipython, pytest) | `runtime` |
---
## Betrieb & Wartung
### Häufige Operationen
```bash
# Logs eines einzelnen Services
docker compose logs -f worker
# In den Container schauen
docker compose exec web bash
docker compose exec db psql -U serienbrief
# Migration nach Modelländerung
docker compose exec web python manage.py makemigrations
docker compose exec web python manage.py migrate
# Statics neu generieren
docker compose exec web python manage.py collectstatic --noinput
# Celery‑ Queue Status
docker compose exec worker celery -A config inspect active
docker compose exec worker celery -A config inspect reserved
```
### Updates
```bash
# Code aktualisieren
git pull
# Image neu bauen
docker compose build --pull
# Mit Zero‑ Downtime nicht möglich (single‑ host) → kurzer Restart
docker compose up -d
# DB‑ Migrationen
docker compose exec web python manage.py migrate
```
### Backup & Restore
```bash
# Manuelles Backup erzwingen
docker compose restart backup
docker compose logs --tail= 50 backup
# Restore (Beispiel)
docker compose exec -T db pg_restore -U serienbrief -d serienbrief --clean < backups/db_20260521_030000.dump
```
### Monitoring (Empfehlung, nicht enthalten)
- `docker stats` für ad‑ hoc Container‑ Ressourcen
- Healthcheck‑ Status: `docker compose ps`
- Container‑ Logs via zentralem syslog/journald (Docker `json-file` rotiert 5× 10 MB)
- Optional: Prometheus + cAdvisor + node_exporter, Loki für Logs
---
## Datenschutz & Compliance
- **Rechtsgrundlage:** Verarbeitung personenbezogener Adress‑ /Empfängerdaten je nach Anwendungsfall Art. 6 Abs. 1 lit. b/c/e DSGVO; bei besonderen Datenkategorien (Art. 9 DSGVO) gesonderte Rechtsgrundlage erforderlich
- **Speicherort:** Innerbetrieblicher Server, kein Cloud‑ Transfer
- **Zugriffssteuerung:** Nur authentifizierte User; Trennung Templates/Jobs aktuell flach — Mandanten‑ /Rollenmodell ggf. ergänzen (siehe Roadmap)
- **Aufbewahrung:** Jobs + CSVs werden nach `JOB_RETENTION_DAYS` (default 30) gelöscht — Cleanup‑ Task noch zu implementieren (siehe Roadmap)
- **Audit:** Jeder Job hat ein `JobLogEntry` ‑ Protokoll (wer, wann, was, Fehler)
- **Backup‑ Verschlüsselung:** Bind‑ Mount `./backups` liegt aktuell unverschlüsselt — für produktiven Einsatz LUKS/Filesystem‑ Encryption am Host **dringend empfohlen**
- **DSFA‑ Relevanz:** Bei Verarbeitung besonderer Datenkategorien oder umfangreicher Profilbildung ist eine Datenschutz‑ Folgenabschätzung gemäß Art. 35 DSGVO durchzuführen, bevor produktiver Echtbetrieb startet
---
## Bekannte Einschränkungen & Roadmap
### Tech‑ Debt
- Postgres‑ Passwort doppelt gehalten (`DATABASE_URL` + Secret‑ Datei) → refactor zu Single Source via Entrypoint, der `DATABASE_URL` aus `*_FILE` ‑ Env zusammenbaut
- Keine LDAP/AD‑ Anbindung — aktuell lokale Django‑ User
- Keine Periodic Tasks im Beat registriert (Retention‑ Cleanup, Log‑ Rotation)
- CSV‑ Header‑ Validierung ist warn‑ only, kein Abbruch bei fehlenden Pflichtfeldern
- Keine Vorschau (Single‑ Letter‑ Preview) vor dem Komplett‑ Render
- Image enthält LibreOffice (~400 MB) — könnte in separates Worker‑ Image ausgelagert werden
### Roadmap (kurzfristig)
- [ ] Single‑ Letter‑ Preview im Job‑ Erstellungs‑ Flow
- [ ] Retention‑ Cleanup als periodic task
- [ ] CSV‑ Validierung strict mit Pflichtfeldliste pro Template
- [ ] Mandanten‑ /Berechtigungsmodell (Abteilung X sieht nur eigene Templates)
- [ ] LDAP‑ Auth über `django‑ auth‑ ldap` (AD‑ Integration GB)
### Roadmap (mittelfristig)
- [ ] AppArmor‑ Profile pro Container
- [ ] Image‑ Scanning in CI (Trivy)
- [ ] Separate Worker‑ Image ohne Web‑ Dependencies
- [ ] AES‑ Verschlüsselung der hochgeladenen CSVs at rest
- [ ] Optional: Konvertierung über Gotenberg statt LibreOffice‑ im‑ Image (cleaneres Separation of Concerns)
---
## Lizenz & Verantwortlichkeit
Internes Projekt. Verantwortlich für Konzept & Betrieb: **Datenschutzkoordination / IT‑ Systemadministration** .
Keine externe Lizenz festgelegt — Verteilung ausschließlich innerbetrieblich.