29 KiB
Production Deployment – Serienbrief
Schritt‑für‑Schritt‑Anleitung für den Go‑Live des Serienbrief‑Stacks. Strukturiert nach Pflicht / Empfehlung / Optional, damit du selbst entscheiden kannst, wie weit du gehen willst.
Zielumgebung: Ubuntu Server LTS 22.04/24.04, Docker Engine 27.x+, Compose v2, externer Reverse‑Proxy mit TLS‑Terminierung (typisch: Nginx Proxy Manager auf separatem Host).
Inhalt
- Vorbereitung
- Pflicht: Konfiguration
- Pflicht: Erststart in Produktion
- Pflicht: Reverse‑Proxy
- Pflicht: Backup verifizieren
- Pflicht: Host‑Hardening
- Pflicht: Smoke‑Test
- Empfehlung: Betriebsautomatisierung
- Empfehlung: Monitoring & Logging
- Empfehlung: Image‑Scanning
- Optional: Verschlüsselung at‑rest
- Optional: AppArmor‑Profile
- Datenschutz‑Dokumentation
- Update & Rollback
- Pre‑Go‑Live‑Checkliste
1. Vorbereitung
Voraussetzungen am Host
# Host‑Patchstand
sudo apt update && sudo apt full-upgrade -y && sudo reboot
# Docker Engine + Compose v2 (Snap‑Paket nicht verwenden — fehlende cgroups‑v2‑Unterstützung)
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
# Host‑User in docker‑Gruppe + re‑login
sudo usermod -aG docker $USER
exec su -l $USER
# Versionen verifizieren
docker version
docker compose version
Repository / Quellbaum platzieren
mkdir -p ~/projekte
cd ~/projekte
# z.B. git clone <interne-repo-url> serienbrief
# oder tar -xzf serienbrief.tgz
cd serienbrief
2. Pflicht: Konfiguration
2.1 .env aus Template ableiten
cp .env.example .env
chmod 600 .env
2.2 Secrets generieren
Django Secret Key (kein Newline, kein =, kein +// falls sed im DATABASE_URL stört — Base64 hier okay, weil keine URL):
DJANGO_KEY=$(openssl rand -base64 64 | tr -d '\n')
sed -i "s|^DJANGO_SECRET_KEY=.*|DJANGO_SECRET_KEY=${DJANGO_KEY}|" .env
unset DJANGO_KEY
Redis‑Passwort (hex statt base64 → keine URL‑Parsing‑Probleme):
REDIS_PW=$(openssl rand -hex 32)
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=${REDIS_PW}|" .env
unset REDIS_PW
Postgres‑Passwort als Datei UND in DATABASE_URL — beide Werte müssen identisch sein:
PG_PW=$(openssl rand -hex 32)
echo -n "$PG_PW" > secrets/postgres_password.txt
chmod 600 secrets/postgres_password.txt
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=postgres://serienbrief:${PG_PW}@db:5432/serienbrief|" .env
unset PG_PW
# Verifikation: identische Werte?
diff <(grep -oP '(?<=://serienbrief:)[^@]+' .env) secrets/postgres_password.txt
# Output leer = identisch
Warum kein Newline? Postgres liest das Secret‑File als Passwort‑String 1:1. Ein abschließendes
\nmacht das Passwort ungültig — du bekommstFATAL: password authentication failed.
Warum hex statt base64? Base64 kann
=,+,/enthalten, die inDATABASE_URLpercent‑encoded werden müssen. Hex ist URL‑safe by design.
2.3 Production‑Werte in .env
Folgende Zeilen anpassen:
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=serienbrief.deine-domain.lan
CSRF_TRUSTED_ORIGINS=https://serienbrief.deine-domain.lan
# WICHTIG: nicht 127.0.0.1, wenn der externe Proxy auf einem anderen Host läuft
APP_BIND_IP=192.168.x.y # LAN-IP des Docker-Hosts
APP_BIND_PORT=8080
# UIDs an Host‑User anpassen (sonst Permission‑denied auf Volumes/Bind‑Mounts)
APP_UID=1000
APP_GID=1000
JOB_RETENTION_DAYS=30
LAN‑IP ermitteln:
ip -4 -br a | grep -v lo
2.4 Verifikation der Settings
# Zeigt, wie Compose die finale Konfiguration zusammenstellt (inkl. expanded ENV)
docker compose -f docker-compose.yml config | less
Prüfen:
DJANGO_SETTINGS_MODULE: config.settings.productionbeiweb,worker,beatAPP_UID/APP_GIDsind1000, nicht10001CELERY_BROKER_URLenthält das echte Redis‑Passwort (nicht${REDIS_PASSWORD})DATABASE_URLenthält das echte Postgres‑Passwort
3. Pflicht: Erststart in Produktion
3.1 Image bauen
docker compose build --pull
--pull holt das aktuellste Base‑Image (Sicherheitsfixes).
3.2 Stack starten — explizit ohne Dev‑Override
docker compose -f docker-compose.yml up -d
Wichtig: Niemals
docker compose up -dohne-fin Produktion verwenden. Compose mergeddocker-compose.override.ymlautomatisch, das hat Dev‑Settings (runserver, Bind‑Mount, debugpy). In Dev istdocker compose up -dkorrekt — hier in Produktion muss-f docker-compose.ymlgesetzt sein.
3.3 Status prüfen
# Alle Services healthy?
docker compose -f docker-compose.yml ps
# Erwartet: status=running, health=healthy für web, nginx, db, redis
# worker, beat, backup haben keinen Healthcheck, müssen aber laufen
3.4 Migrationen verifizieren
Der Entrypoint führt migrate automatisch aus. Manuell nachfahren:
docker compose -f docker-compose.yml exec web python manage.py migrate
docker compose -f docker-compose.yml exec web python manage.py showmigrations mailmerge
0002_retention_cleanup_periodic_task muss als [X] markiert sein.
3.5 Superuser anlegen
docker compose -f docker-compose.yml exec web python manage.py createsuperuser
Starkes Passwort (Passwort‑Manager). Kein admin/admin.
3.6 Periodic Task verifizieren
Im Browser: https://serienbrief.deine-domain.lan/admin/django_celery_beat/periodictask/
Eintrag „Retention‑Cleanup: abgelaufene Jobs löschen" muss vorhanden sein, Enabled = True, Crontab 15 3 * * *.
Optional: Beat‑Logs beobachten
docker compose -f docker-compose.yml logs -f beat
4. Pflicht: Reverse‑Proxy
Der Stack liefert intern HTTP auf ${APP_BIND_IP}:${APP_BIND_PORT} aus. Der externe Proxy übernimmt TLS.
4.1 Firewall‑Regel für den Proxy
# Nur der Proxy‑Host darf auf 8080
sudo ufw allow from <proxy-host-ip> to any port 8080 proto tcp comment 'reverse proxy'
Siehe 6.1 Firewall.
4.2 Variante A: Nginx Proxy Manager
Im NPM‑Webinterface → Proxy Hosts → Add Proxy Host:
| Feld | Wert |
|---|---|
| Domain Names | serienbrief.deine-domain.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 |
Tab Advanced:
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;
X-Forwarded-Proto $scheme ist Pflicht, sonst greift Djangos SECURE_PROXY_SSL_HEADER nicht und HTTPS‑Redirects laufen in eine Schleife.
Tab SSL: Zertifikat zuweisen (Let's Encrypt via DNS‑Challenge für interne Domains oder eigene CA).
4.3 Variante B: Generisches Nginx
server {
listen 443 ssl http2;
server_name serienbrief.deine-domain.lan;
ssl_certificate /etc/ssl/lan/serienbrief.crt;
ssl_certificate_key /etc/ssl/lan/serienbrief.key;
# HSTS — der externe Proxy ist der TLS-Terminator
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
client_max_body_size 50M;
location / {
proxy_pass http://<docker-host-ip>:8080;
proxy_http_version 1.1;
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_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
4.4 Diagnose bei 502 Bad Gateway
# Vom Proxy-Host aus testen:
nc -zv <docker-host-ip> 8080
curl -v -H "Host: serienbrief.deine-domain.lan" http://<docker-host-ip>:8080/
| Symptom | Ursache | Fix |
|---|---|---|
Connection refused |
APP_BIND_IP=127.0.0.1 |
LAN‑IP setzen, docker compose up -d |
Timeout |
UFW/iptables blockt | Regel ergänzen |
| 502 ohne Connect‑Fehler | falsche Upstream‑IP im Proxy | Proxy‑Konfig prüfen |
| 400 Bad Request | Domain fehlt in DJANGO_ALLOWED_HOSTS |
.env anpassen, restart web |
| CSRF 403 beim POST | Domain fehlt in CSRF_TRUSTED_ORIGINS |
.env anpassen, restart web |
5. Pflicht: Backup verifizieren
Ein Backup, das nie restored wurde, ist kein Backup.
5.1 Backup manuell triggern
docker compose -f docker-compose.yml restart backup
docker compose -f docker-compose.yml logs --tail=80 backup
ls -la backups/
Erwartet:
db_YYYYMMDD_HHMMSS.dump(pg_dump im Custom‑Format)media_YYYYMMDD_HHMMSS.tar.gz
5.2 Restore‑Test auf Wegwerf‑Container
# Wegwerf‑Postgres
docker run --rm -d --name pgtest \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_USER=serienbrief \
-e POSTGRES_DB=serienbrief \
postgres:16-alpine
# Warten bis ready
sleep 5
# Dump reinkopieren und restoren
LATEST=$(ls -t backups/db_*.dump | head -1)
docker cp "$LATEST" pgtest:/tmp/restore.dump
docker exec pgtest pg_restore -U serienbrief -d serienbrief --clean --if-exists /tmp/restore.dump
# Verifikation: existieren Tabellen?
docker exec pgtest psql -U serienbrief -d serienbrief -c "\dt mailmerge_*"
# Aufräumen
docker rm -f pgtest
5.3 Backup‑Retention prüfen
docker-compose.yml: backup‑Service hat Default 14 Tage. Bei JOB_RETENTION_DAYS=30 ist das knapp — Backup‑Retention sollte >= JOB_RETENTION_DAYS + ein paar Tage sein, sonst gehen DSGVO‑relevante Audit‑Spuren verloren, bevor du sie aus dem Backup wiederherstellen könntest.
Anpassen über die BACKUP_RETENTION_DAYS‑Variable (falls vorgesehen) oder direkt im Service‑Skript.
5.4 Off‑Site‑Kopie
Backups liegen aktuell nur auf der Host‑Disk. Für produktiven Einsatz: mindestens nightly auf ein zweites Ziel.
# Beispiel: rsync auf zweiten Server, key-based auth, dediziertes Restricted-Account
rsync -avz --delete \
-e "ssh -i /home/hans/.ssh/backup_ed25519" \
/home/hans/projekte/serienbrief/backups/ \
backup@backup-host.lan:/srv/backups/serienbrief/
Cron:
30 4 * * * /home/hans/scripts/serienbrief-offsite.sh
6. Pflicht: Host‑Hardening
6.1 Firewall (UFW)
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH — nur vom Admin-Netz
sudo ufw allow from 192.168.0.0/16 to any port 22 proto tcp comment 'ssh admin lan'
# Docker-App-Port — nur vom Reverse-Proxy
sudo ufw allow from <proxy-host-ip> to any port 8080 proto tcp comment 'reverse proxy'
sudo ufw enable
sudo ufw status verbose
Docker + UFW: Docker umgeht UFW, weil es eigene
iptables‑Regeln in derDOCKER-USER‑Chain schreibt. Solange duAPP_BIND_IPauf eine konkrete LAN‑IP (nicht0.0.0.0) setzt, ist der Port nur auf diesem Interface offen und UFW greift zuverlässig.
6.2 SSH‑Hardening
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 30
AllowGroups ssh-users
EOF
sudo groupadd -f ssh-users
sudo usermod -aG ssh-users hans
sudo sshd -t # Konfig syntaktisch ok?
sudo systemctl reload ssh
Vorher: SSH‑Key des Admin‑Workplatzes in ~/.ssh/authorized_keys hinterlegen und in einer zweiten SSH‑Session testen, bevor die alte Session geschlossen wird.
6.3 fail2ban
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local >/dev/null <<'EOF'
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd
6.4 Unattended Upgrades
sudo apt install -y unattended-upgrades apt-listchanges
sudo tee /etc/apt/apt.conf.d/52unattended-serienbrief >/dev/null <<'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
};
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Mail "ops@deine-domain.lan";
Unattended-Upgrade::MailReport "on-change";
EOF
sudo systemctl enable --now unattended-upgrades
sudo unattended-upgrade --dry-run --debug | tail -30
Reboot bewusst manuell, weil Docker‑Stack‑Restart sonst zur Unzeit passiert.
6.5 Audit‑Logging
sudo apt install -y auditd
# Default-Regeln reichen für Standard-Compliance.
sudo systemctl enable --now auditd
7. Pflicht: Smoke‑Test
Vor dem offiziellen Go‑Live: einmal vollständig durchklicken.
7.1 Funktionaler Smoke‑Test
| Schritt | URL | Erwartung |
|---|---|---|
| 1. Startseite | https://serienbrief.deine-domain.lan/ |
Redirect zu /accounts/login/ |
| 2. Login | /accounts/login/ |
Login mit Superuser funktioniert |
| 3. Template hochladen | /admin/mailmerge/lettertemplate/add/ |
DOCX akzeptiert, placeholders automatisch erkannt |
| 4. Job‑Form | /jobs/new/ (o.ä.) |
Vorlage + CSV auswählbar |
| 5. Preview | Klick „Vorschau (erste Zeile)" | iframe zeigt PDF inline, < 5 s |
| 6. Job starten | „Job starten" | Job läuft, Status wechselt queued → running → done |
| 7. Download | Klick auf Output‑PDF | PDF lädt herunter |
| 8. Logout | Logout‑Button | redirect zu Login |
| 9. Lockout | 5× falsches Passwort | django-axes lockt aus |
7.2 Sicherheits‑Smoke‑Test
# Direktzugriff auf den Docker-Host umgehend den Proxy — muss blockiert sein
curl -kv https://<docker-host-ip>:443/ # ConnectionRefused erwartet, weil 443 nicht offen
# Direkt auf 8080 von einem Nicht-Proxy-Host — muss blockiert sein
curl -v http://<docker-host-ip>:8080/ # Timeout/ConnectionRefused erwartet
# Postgres / Redis dürfen NICHT von außen erreichbar sein
nc -zv <docker-host-ip> 5432 # erwartet: closed
nc -zv <docker-host-ip> 6379 # erwartet: closed
# CSRF-Token Pflicht
curl -k -X POST https://serienbrief.deine-domain.lan/accounts/login/ \
-d "username=admin&password=test"
# erwartet: 403 (CSRF verification failed)
7.3 Test‑Suite im Container
docker compose -f docker-compose.yml exec web pytest mailmerge/tests/ -v
# erwartet: alle Tests grün
8. Empfehlung: Betriebsautomatisierung
8.1 systemd‑Unit für den Stack
Damit der Stack nach Host‑Reboot deterministisch startet (vor allem nach Kernel‑Updates). restart: unless-stopped allein reicht nicht, wenn die Container vor dem Reboot gestoppt waren.
/etc/systemd/system/serienbrief.service:
[Unit]
Description=Serienbrief Docker Compose Stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
User=hans
Group=hans
WorkingDirectory=/home/hans/projekte/serienbrief
ExecStart=/usr/bin/docker compose -f docker-compose.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.yml down
TimeoutStartSec=180
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now serienbrief
sudo systemctl status serienbrief
Beachte:
-f docker-compose.ymlohne Override — verhindert, dass nach einem Reboot das Dev‑Override greift.
8.2 Health‑Watchdog (Cron)
~/projekte/serienbrief/scripts/health-check.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /home/hans/projekte/serienbrief
UNHEALTHY=$(docker compose -f docker-compose.yml ps --format json \
| jq -r 'select(.Health == "unhealthy" or .State == "exited") | .Service')
if [[ -n "$UNHEALTHY" ]]; then
{
echo "Serienbrief: ungesunde Services auf $(hostname)"
echo "Zeitpunkt: $(date -Iseconds)"
echo
echo "$UNHEALTHY"
echo
echo "--- ps ---"
docker compose -f docker-compose.yml ps
} | mail -s "[ALERT] Serienbrief unhealthy" ops@deine-domain.lan
fi
chmod +x ~/projekte/serienbrief/scripts/health-check.sh
crontab -e
# Eintrag:
*/15 * * * * /home/hans/projekte/serienbrief/scripts/health-check.sh
Voraussetzungen: jq und ein lokaler Mail‑Setup (msmtp, postfix, etc.) oder Anbindung an Uptime Kuma / Healthchecks.io.
8.3 Tägliche Job‑Statistik (optional)
Cron‑Eintrag, der einmal täglich eine Status‑Mail schickt:
0 7 * * * cd /home/hans/projekte/serienbrief && \
docker compose -f docker-compose.yml exec -T web python manage.py shell -c \
"from mailmerge.models import MailMergeJob; \
from django.utils import timezone; from datetime import timedelta; \
cutoff=timezone.now()-timedelta(days=1); \
qs=MailMergeJob.objects.filter(created_at__gte=cutoff); \
print(f'Last 24h: total={qs.count()} done={qs.filter(status=\"done\").count()} failed={qs.filter(status=\"failed\").count()}')" \
| mail -s "Serienbrief daily" ops@deine-domain.lan
9. Empfehlung: Monitoring & Logging
9.1 Log‑Rotation des Docker‑Daemons
Aktuell: json-file mit Rotation auf 5x10 MB (siehe x-logging in docker-compose.yml). Für längeren Aufbewahrungsbedarf:
sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "10"
}
}
EOF
sudo systemctl restart docker
# Container müssen anschließend neu erstellt werden, damit die neue Default-Policy greift
docker compose -f docker-compose.yml up -d --force-recreate
9.2 Zentralisiertes Logging (optional)
- Minimal:
journaldals Log‑Driver, dann viarsyslog/vector/promtailweiterreichen - Komfort: Loki + Grafana, bei bestehender Infrastruktur einfach anzubinden
Konfig pro Service in docker-compose.yml:
logging:
driver: journald
options:
tag: "serienbrief.{{.Name}}"
9.3 Metriken (optional)
cAdvisor + node_exporter + Prometheus für Container‑Metriken; Django‑Prometheus für App‑interne Counter (Job‑Anzahl, Render‑Dauer).
10. Empfehlung: Image‑Scanning
10.1 Trivy vor jedem Build
# Installation (host-side, einmalig)
sudo apt install -y wget apt-transport-https gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main | \
sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update && sudo apt install -y trivy
# Scan
trivy image --severity HIGH,CRITICAL --exit-code 1 \
--ignore-unfixed serienbrief/web:1.0.0
10.2 pip‑audit für Python‑Dependencies
docker compose -f docker-compose.yml run --rm web pip-audit
In requirements-dev.txt aufnehmen, damit es im Dev‑Image vorhanden ist.
10.3 Build‑Pipeline (Skizze)
~/projekte/serienbrief/scripts/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /home/hans/projekte/serienbrief
git pull
docker compose -f docker-compose.yml build --pull
# Sicherheits-Gate
trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed \
serienbrief/web:$(grep ^APP_VERSION .env | cut -d= -f2)
# Tests im neuen Image
docker compose -f docker-compose.yml run --rm web pytest mailmerge/tests/ --tb=line
# Migrieren + Deployen
docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml exec -T web python manage.py migrate --noinput
# Health-Check nach Rollout
sleep 10
docker compose -f docker-compose.yml ps
11. Optional: Verschlüsselung at‑rest
Backups, media_files und postgres_data liegen aktuell unverschlüsselt auf der Host‑Disk. Bei Hardware‑Diebstahl / Wiederverwendung sind personenbezogene Daten lesbar.
11.1 LUKS auf der Daten‑Partition
Auf dem Host vor der Erstinstallation einrichten. Nachträglich nur mit Daten‑Migration auf neuen Volumes möglich.
- Separate Partition für
/var/lib/docker(Volumes) und~/projekte/serienbrief/backups - LUKS2 mit Argon2id KDF
- Schlüssel via TPM2 + clevis automatisch beim Boot entsperren, kein Passphrase‑Prompt
- Recovery‑Key in Tresor
11.2 Alternative: Filesystem‑Encryption
- ZFS mit nativer Encryption (
zfs set encryption=on …) - ext4 + fscrypt (Per‑Directory‑Encryption)
11.3 Backup‑Verschlüsselung
Falls die Daten‑Partition nicht verschlüsselt wird, mindestens die Backups verschlüsseln:
gpg --encrypt --recipient ops-backup@deine-domain.lan \
--output backups/db_$(date +%Y%m%d).dump.gpg \
backups/db_$(date +%Y%m%d).dump
Empfänger‑Key sicher aufbewahrt, nicht auf demselben Host.
12. Optional: AppArmor‑Profile
Default‑Docker‑AppArmor‑Profil ist okay. Für web/worker lassen sich striktere Profile schreiben (kein mount, kein ptrace, kein Zugriff außerhalb /app, /tmp, /var/log):
# in docker-compose.yml unter web/worker:
security_opt:
- apparmor=docker-serienbrief
Profil unter /etc/apparmor.d/docker-serienbrief ablegen, mit sudo apparmor_parser -r laden. Skizze:
#include <tunables/global>
profile docker-serienbrief flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
#include <abstractions/python>
/app/** rk,
/tmp/** rwk,
/usr/local/lib/python3.12/** rk,
/usr/lib/libreoffice/** rk,
deny capability sys_admin,
deny capability sys_module,
deny capability sys_ptrace,
deny mount,
}
Erst im Staging testen, falsche Pfade führen sofort zu Container‑Crashes.
13. Datenschutz‑Dokumentation
Vor Echtbetrieb folgende Dokumente aktualisieren:
13.1 VVT‑Eintrag (Verzeichnis von Verarbeitungstätigkeiten)
| Feld | Beispielinhalt |
|---|---|
| Bezeichnung | Serienbrief‑System |
| Zweck | Erzeugung personalisierter Anschreiben aus DOCX‑Vorlagen |
| Rechtsgrundlage | Art. 6 Abs. 1 lit. b/c/e DSGVO (anwendungsabhängig) |
| Datenkategorien | Anrede, Vor‑/Nachname, Anschrift, ggf. Personalnummer, Funktion |
| Betroffene | Mitarbeiter:innen / Kunden / etc. (use‑case‑abhängig) |
| Empfänger | Postlogistik (sofern PDF gedruckt/versandt) |
| Aufbewahrung | 30 Tage (JOB_RETENTION_DAYS), automatische Löschung |
| Drittstaaten | nein |
| TOMs | siehe 13.2 |
13.2 Technisch‑organisatorische Maßnahmen (TOMs)
| Schutzziel | Maßnahme |
|---|---|
| Vertraulichkeit | TLS am externen Proxy, Auth via Django + django‑axes, Container non‑root |
| Integrität | CSRF, signed Cookies, Audit‑Log pro Job |
| Verfügbarkeit | tägliches Backup + verifizierter Restore, Healthchecks |
| Belastbarkeit | Compose‑Restart‑Policy, systemd‑Unit, Resource‑Limits |
| Wiederherstellbarkeit | pg_restore aus Backup, dokumentiert |
| Eingabekontrolle | JobLogEntry protokolliert created_by, Zeitstempel, Statuswechsel |
| Auftragskontrolle | n/a (kein Auftragsverarbeiter) |
| Trennungskontrolle | Mandanten‑/Rollenmodell ⚠️ noch offen — siehe Roadmap |
| Löschung | mailmerge.cleanup_expired_jobs täglich, manuell per cleanup_jobs Command |
13.3 DSFA‑Trigger
Eine Datenschutz‑Folgenabschätzung gemäß Art. 35 DSGVO ist erforderlich, wenn:
- besondere Datenkategorien (Art. 9) verarbeitet werden (Gesundheitsdaten etc.)
- umfangreiche Profilbildung erfolgt
- systematische Überwachung großer Personenmengen stattfindet
→ Vor Echtbetrieb durch DSB/DPO bewerten.
13.4 Löschnachweis
Der Retention‑Cleanup hinterlässt keinen persistenten Audit‑Eintrag, weil der Job selbst gelöscht wird. Falls Nachweispflicht: Beat‑Logs zentralisieren und mindestens JOB_RETENTION_DAYS + Aufbewahrungsfrist Log aufbewahren.
14. Update & Rollback
14.1 Standard‑Update
cd ~/projekte/serienbrief
git pull
docker compose -f docker-compose.yml build --pull
docker compose -f docker-compose.yml up -d
docker compose -f docker-compose.yml exec web python manage.py migrate --noinput
14.2 Image vor Update sichern (für Rollback)
OLD_VERSION=$(grep ^APP_VERSION .env | cut -d= -f2)
docker tag serienbrief/web:${OLD_VERSION} serienbrief/web:rollback-${OLD_VERSION}-$(date +%Y%m%d)
14.3 Rollback
# .env: APP_VERSION auf alte Version zurücksetzen
sed -i "s|^APP_VERSION=.*|APP_VERSION=${OLD_VERSION}|" .env
docker tag serienbrief/web:rollback-${OLD_VERSION}-YYYYMMDD serienbrief/web:${OLD_VERSION}
docker compose -f docker-compose.yml up -d
# DB-Migration zurückrollen (nur wenn nötig, vorsichtig):
docker compose -f docker-compose.yml exec web python manage.py migrate mailmerge <vorherige_migration>
Backwards‑Compatibility der Migrationen ist kein Selbstläufer. Vor Schema‑Änderungen, die nicht reversibel sind, immer vorher pg_dump erstellen und im Notfall einspielen.
15. Pre‑Go‑Live‑Checkliste
Abhaken vor dem offiziellen Go‑Live:
Konfiguration
.enventhält echte Secrets, keinCHANGE_MEmehr.envundsecrets/postgres_password.txtsind0600, gehörenhans:hansDJANGO_DEBUG=FalseDJANGO_ALLOWED_HOSTSenthält die produktive DomainCSRF_TRUSTED_ORIGINSenthälthttps://<produktive-domain>APP_BIND_IPauf LAN‑IP gesetzt (nicht 127.0.0.1, falls Proxy extern)APP_UID=1000/APP_GID=1000(an Host‑User)JOB_RETENTION_DAYSan Geschäftsanforderung angepasst- Postgres‑Passwort in
.envund Secret‑File identisch
Stack‑Lauf
docker compose -f docker-compose.yml pszeigt alle Serviceshealthy- Periodic Task
Retention-Cleanupin Admin sichtbar und enabled - Superuser angelegt, starkes Passwort
- Test‑Suite läuft grün
Reverse‑Proxy
- TLS‑Zertifikat gültig
X-Forwarded-Proto $schemegesetztclient_max_body_size>= 50M- Externe URL liefert Login‑Seite (kein 502)
- HSTS am externen Proxy aktiv
Host
- UFW aktiv, nur 22 + 8080 (vom Proxy) erlaubt
- SSH: keine Passwort‑Auth, kein Root, AllowGroups gesetzt
- fail2ban läuft
- Unattended‑Upgrades aktiv
- systemd‑Unit
serienbrief.serviceenabled
Backup
- Backup‑Service läuft, schreibt Dumps
- Restore‑Test auf Wegwerf‑Container erfolgreich
- Off‑Site‑Kopie eingerichtet (cron auf zweiten Server)
- Backup‑Retention >=
JOB_RETENTION_DAYS+ Sicherheitspuffer
Monitoring
- Health‑Check‑Cron läuft, Test‑Alarm verifiziert
- Log‑Driver konfiguriert (Rotation oder zentral)
Sicherheits‑Smoke‑Test
- Postgres‑Port 5432 nicht von außen erreichbar
- Redis‑Port 6379 nicht von außen erreichbar
- Direkter Zugriff auf 8080 nur vom Proxy möglich
- 5 Fehlversuche → django‑axes Lockout funktioniert
- CSRF wird erzwungen
Funktionaler Smoke‑Test
- Template hochladen
- CSV hochladen, Preview erzeugt PDF inline
- Vollständiger Job läuft durch, PDF downloadbar
- Logout funktioniert
- Retention‑Cleanup im Dry‑Run liefert plausibles Ergebnis
Dokumentation
- VVT‑Eintrag aktualisiert
- TOMs dokumentiert
- DSFA‑Pflicht bewertet (ggf. durchgeführt)
- Notfall‑Kontakte hinterlegt
- Restore‑Prozedur dokumentiert und im Notfallhandbuch verlinkt
Anhang: Häufige Fehler & Fixes
| Symptom | Ursache | Fix |
|---|---|---|
permission denied bei makemigrations |
UID‑Mismatch Container vs. Host | APP_UID=1000 in .env, docker compose build --no-cache web |
502 Bad Gateway am externen Proxy |
APP_BIND_IP=127.0.0.1 oder Firewall |
LAN‑IP setzen, UFW‑Regel |
CSRF verification failed beim Login |
Domain fehlt in CSRF_TRUSTED_ORIGINS |
.env ergänzen, restart web |
Logout liefert 405 Method Not Allowed |
GET‑Logout in Django 5 abgeschafft | POST‑Form (bereits in base.html) |
network ... not found beim Compose‑Start |
docker compose -f docker-compose.yml ohne Override → fehlende Bridge |
Override behalten oder Netzwerk in docker-compose.yml definieren |
| Preview‑PDF leer/kaputt | LibreOffice‑User‑Profil verwaist im tmpfs | Container neu starten, --max-tasks-per-child greift normalerweise |
FATAL: password authentication failed (Postgres) |
Newline am Ende von secrets/postgres_password.txt |
echo -n statt echo, tr -d '\n', Datei erneut schreiben |
| Cleanup löscht keine Dateien (nur DB‑Zeilen) | Bulk‑qs.delete() ohne FileField‑Hooks |
Aktuelle retention.py iteriert einzeln — Code‑Stand verifizieren |
Verantwortlich: Datenschutzkoordination / IT‑Systemadministration Letzte Überprüfung: Datum hier eintragen Nächste Überprüfung: spätestens nach Major‑Update oder einmal jährlich