# 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 1. [Vorbereitung](#1-vorbereitung) 2. [Pflicht: Konfiguration](#2-pflicht-konfiguration) 3. [Pflicht: Erststart in Produktion](#3-pflicht-erststart-in-produktion) 4. [Pflicht: Reverse‑Proxy](#4-pflicht-reverseproxy) 5. [Pflicht: Backup verifizieren](#5-pflicht-backup-verifizieren) 6. [Pflicht: Host‑Hardening](#6-pflicht-hosthardening) 7. [Pflicht: Smoke‑Test](#7-pflicht-smoketest) 8. [Empfehlung: Betriebsautomatisierung](#8-empfehlung-betriebsautomatisierung) 9. [Empfehlung: Monitoring & Logging](#9-empfehlung-monitoring--logging) 10. [Empfehlung: Image‑Scanning](#10-empfehlung-imagescanning) 11. [Optional: Verschlüsselung at‑rest](#11-optional-verschl%C3%BCsselung-atrest) 12. [Optional: AppArmor‑Profile](#12-optional-apparmorprofile) 13. [Datenschutz‑Dokumentation](#13-datenschutzdokumentation) 14. [Update & Rollback](#14-update--rollback) 15. [Pre‑Go‑Live‑Checkliste](#15-pregolivecheckliste) --- ## 1. Vorbereitung ### Voraussetzungen am Host ```bash # 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 ```bash mkdir -p ~/projekte cd ~/projekte # z.B. git clone serienbrief # oder tar -xzf serienbrief.tgz cd serienbrief ``` --- ## 2. Pflicht: Konfiguration ### 2.1 `.env` aus Template ableiten ```bash 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): ```bash 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): ```bash 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: ```bash 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 `\n` macht das Passwort ungültig — du bekommst `FATAL: password authentication failed`. > **Warum hex statt base64?** Base64 kann `=`, `+`, `/` enthalten, die in `DATABASE_URL` percent‑encoded werden müssen. Hex ist URL‑safe by design. ### 2.3 Production‑Werte in `.env` Folgende Zeilen anpassen: ```env 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: ```bash ip -4 -br a | grep -v lo ``` ### 2.4 Verifikation der Settings ```bash # 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.production` bei `web`, `worker`, `beat` - `APP_UID`/`APP_GID` sind `1000`, nicht `10001` - `CELERY_BROKER_URL` enthält das echte Redis‑Passwort (nicht `${REDIS_PASSWORD}`) - `DATABASE_URL` enthält das echte Postgres‑Passwort --- ## 3. Pflicht: Erststart in Produktion ### 3.1 Image bauen ```bash docker compose build --pull ``` `--pull` holt das aktuellste Base‑Image (Sicherheitsfixes). ### 3.2 Stack starten — **explizit ohne Dev‑Override** ```bash docker compose -f docker-compose.yml up -d ``` > **Wichtig:** Niemals `docker compose up -d` ohne `-f` in Produktion verwenden. Compose merged `docker-compose.override.yml` automatisch, das hat Dev‑Settings (`runserver`, Bind‑Mount, debugpy). In Dev ist `docker compose up -d` korrekt — hier in Produktion **muss** `-f docker-compose.yml` gesetzt sein. ### 3.3 Status prüfen ```bash # 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: ```bash 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 ```bash 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 ```bash 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 ```bash # Nur der Proxy‑Host darf auf 8080 sudo ufw allow from to any port 8080 proto tcp comment 'reverse proxy' ``` Siehe [6.1 Firewall](#61-firewall-ufw). ### 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**: ```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; ``` `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 ```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://: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 ```bash # Vom Proxy-Host aus testen: nc -zv 8080 curl -v -H "Host: serienbrief.deine-domain.lan" http://: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 ```bash 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 ```bash # 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. ```bash # 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: ```cron 30 4 * * * /home/hans/scripts/serienbrief-offsite.sh ``` --- ## 6. Pflicht: Host‑Hardening ### 6.1 Firewall (UFW) ```bash 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 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 der `DOCKER-USER`‑Chain schreibt. Solange du `APP_BIND_IP` auf eine konkrete LAN‑IP (nicht `0.0.0.0`) setzt, ist der Port nur auf diesem Interface offen und UFW greift zuverlässig. ### 6.2 SSH‑Hardening ```bash 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 ```bash 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 ```bash 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 ```bash 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 ```bash # Direktzugriff auf den Docker-Host umgehend den Proxy — muss blockiert sein curl -kv https://:443/ # ConnectionRefused erwartet, weil 443 nicht offen # Direkt auf 8080 von einem Nicht-Proxy-Host — muss blockiert sein curl -v http://:8080/ # Timeout/ConnectionRefused erwartet # Postgres / Redis dürfen NICHT von außen erreichbar sein nc -zv 5432 # erwartet: closed nc -zv 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 ```bash 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`: ```ini [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 ``` ```bash sudo systemctl daemon-reload sudo systemctl enable --now serienbrief sudo systemctl status serienbrief ``` > Beachte: `-f docker-compose.yml` ohne Override — verhindert, dass nach einem Reboot das Dev‑Override greift. ### 8.2 Health‑Watchdog (Cron) `~/projekte/serienbrief/scripts/health-check.sh`: ```bash #!/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 ``` ```bash 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: ```cron 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: ```bash 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**: `journald` als Log‑Driver, dann via `rsyslog`/`vector`/`promtail` weiterreichen - **Komfort**: Loki + Grafana, bei bestehender Infrastruktur einfach anzubinden Konfig pro Service in `docker-compose.yml`: ```yaml 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 ```bash # 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 ```bash 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`: ```bash #!/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: ```bash 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`): ```yaml # 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: ```text #include profile docker-serienbrief flags=(attach_disconnected,mediate_deleted) { #include #include /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 ```bash 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) ```bash 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 ```bash # .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 ``` > 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 - [ ] `.env` enthält echte Secrets, kein `CHANGE_ME` mehr - [ ] `.env` und `secrets/postgres_password.txt` sind `0600`, gehören `hans:hans` - [ ] `DJANGO_DEBUG=False` - [ ] `DJANGO_ALLOWED_HOSTS` enthält die produktive Domain - [ ] `CSRF_TRUSTED_ORIGINS` enthält `https://` - [ ] `APP_BIND_IP` auf LAN‑IP gesetzt (nicht 127.0.0.1, falls Proxy extern) - [ ] `APP_UID=1000` / `APP_GID=1000` (an Host‑User) - [ ] `JOB_RETENTION_DAYS` an Geschäftsanforderung angepasst - [ ] Postgres‑Passwort in `.env` und Secret‑File identisch ### Stack‑Lauf - [ ] `docker compose -f docker-compose.yml ps` zeigt alle Services `healthy` - [ ] Periodic Task `Retention-Cleanup` in Admin sichtbar und enabled - [ ] Superuser angelegt, starkes Passwort - [ ] Test‑Suite läuft grün ### Reverse‑Proxy - [ ] TLS‑Zertifikat gültig - [ ] `X-Forwarded-Proto $scheme` gesetzt - [ ] `client_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.service` enabled ### 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_