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
- Funktionsumfang
- Architekturübersicht
- Tech‑Stack
- Komponenten im Detail
- Verzeichnisstruktur
- Datenfluss eines Serienbrief‑Jobs
- Datenmodell
- Sicherheitskonzept
- Netzwerk & Ports
- Volumes & Persistenz
- Konfiguration
- Inbetriebnahme
- Entwicklung in VS Code
- Betrieb & Wartung
- Datenschutz & Compliance
- 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‑Redirectfü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(perROLE‑Env unterschieden, gleiches Image wie worker/beat) - Stateless, read‑only Root‑FS,
/tmpals tmpfs - Settings‑Split:
base.py,dev.py,production.py - Entrypoint wartet auf DB‑Readiness, führt
migrateund (in Prod)collectstaticaus - 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
DatabaseSchedulervon 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: 256mbfür komplexere Queries
redis – Broker & Result Backend
- Passwortgeschützt (
REDIS_PASSWORDaus.env) - AOF persistence + RDB snapshot (
save 900 1) maxmemory 256mbmitallkeys‑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
./backupsauf 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
- Upload Vorlage:
LetterTemplatemit DOCX‑Datei wird angelegt (Admin oder UI) - Job‑Erstellung: User wählt Vorlage + lädt CSV hoch →
MailMergeJob(status=queued) - Enqueue: View löst
process_mailmerge_job.delay(job.pk)aus - 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 JobLogEntrys pro Schritt; Statusrunning → donebzw.failed
- UI‑Polling: HTMX fragt
/jobs/<id>/status/alle ~2 s ab → Fortschrittsbalken - Download: Nutzer klickt Download → View prüft Auth → Antwort enthält
X‑Accel‑Redirect: /protected/…→ Nginx streamt das PDF
Datenmodell
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: truebei allen Servicesread_only: truefürweb,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
nginxbindet 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) .envundsecrets/sind in.gitignoreausgenommen
Anwendungs‑Sicherheit
- django‑axes: Lockout nach n Fehlversuchen
- CSRF aktiv, Trusted Origins explizit gepflegt
SECURE_PROXY_SSL_HEADERgesetzt, damit Django HTTPS hinter dem externen Proxy korrekt erkenntUSE_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:
# 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.txtmuss exakt denselben Wert enthalten wie inDATABASE_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 inDATABASE_URLbricht
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
# 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
Externen Proxy konfigurieren (Beispiel)
server {
listen 443 ssl http2;
server_name serienbrief.lan;
ssl_certificate /etc/ssl/lan/serienbrief.crt;
ssl_certificate_key /etc/ssl/lan/serienbrief.key;
location / {
proxy_pass http://<docker-host-ip>:8080;
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;
proxy_redirect off;
client_max_body_size 50M; # für CSV/DOCX‑Uploads
}
}
Entwicklung in VS Code
.devcontainer/devcontainer.jsonreferenziert den Compose‑Stack mit Dev‑Override.vscode/launch.jsonenthält Configs:- Django: runserver (attach) → debugpy auf 5678
- Django: pytest → führt Tests im Container aus
.vscode/tasks.jsonfürmakemigrations,migrate,shell,collectstatic.vscode/extensions.jsonempfiehlt: Python, Pylance, Django, Docker, Even Better TOML, Ruff
Workflow
# 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
# 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
# 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
# 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 statsfür ad‑hoc Container‑Ressourcen- Healthcheck‑Status:
docker compose ps - Container‑Logs via zentralem syslog/journald (Docker
json-filerotiert 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
./backupsliegt 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, derDATABASE_URLaus*_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.