Files
2026-05-22 09:15:59 +02:00

29 KiB
Raw Permalink Blame History

Production Deployment Serienbrief

SchrittfürSchrittAnleitung für den GoLive des SerienbriefStacks. 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 ReverseProxy mit TLSTerminierung (typisch: Nginx Proxy Manager auf separatem Host).


Inhalt

  1. Vorbereitung
  2. Pflicht: Konfiguration
  3. Pflicht: Erststart in Produktion
  4. Pflicht: ReverseProxy
  5. Pflicht: Backup verifizieren
  6. Pflicht: HostHardening
  7. Pflicht: SmokeTest
  8. Empfehlung: Betriebsautomatisierung
  9. Empfehlung: Monitoring & Logging
  10. Empfehlung: ImageScanning
  11. Optional: Verschlüsselung atrest
  12. Optional: AppArmorProfile
  13. DatenschutzDokumentation
  14. Update & Rollback
  15. PreGoLiveCheckliste

1. Vorbereitung

Voraussetzungen am Host

# HostPatchstand
sudo apt update && sudo apt full-upgrade -y && sudo reboot

# Docker Engine + Compose v2 (SnapPaket nicht verwenden — fehlende cgroupsv2Unterstü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

# HostUser in dockerGruppe + relogin
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

RedisPasswort (hex statt base64 → keine URLParsingProbleme):

REDIS_PW=$(openssl rand -hex 32)
sed -i "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=${REDIS_PW}|" .env
unset REDIS_PW

PostgresPasswort 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 SecretFile als PasswortString 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 percentencoded werden müssen. Hex ist URLsafe by design.

2.3 ProductionWerte 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 HostUser anpassen (sonst Permissiondenied auf Volumes/BindMounts)
APP_UID=1000
APP_GID=1000

JOB_RETENTION_DAYS=30

LANIP 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.production bei web, worker, beat
  • APP_UID/APP_GID sind 1000, nicht 10001
  • CELERY_BROKER_URL enthält das echte RedisPasswort (nicht ${REDIS_PASSWORD})
  • DATABASE_URL enthält das echte PostgresPasswort

3. Pflicht: Erststart in Produktion

3.1 Image bauen

docker compose build --pull

--pull holt das aktuellste BaseImage (Sicherheitsfixes).

3.2 Stack starten — explizit ohne DevOverride

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 DevSettings (runserver, BindMount, debugpy). In Dev ist docker compose up -d korrekt — hier in Produktion muss -f docker-compose.yml gesetzt 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 (PasswortManager). Kein admin/admin.

3.6 Periodic Task verifizieren

Im Browser: https://serienbrief.deine-domain.lan/admin/django_celery_beat/periodictask/

Eintrag „RetentionCleanup: abgelaufene Jobs löschen" muss vorhanden sein, Enabled = True, Crontab 15 3 * * *.

Optional: BeatLogs beobachten

docker compose -f docker-compose.yml logs -f beat

4. Pflicht: ReverseProxy

Der Stack liefert intern HTTP auf ${APP_BIND_IP}:${APP_BIND_PORT} aus. Der externe Proxy übernimmt TLS.

4.1 FirewallRegel für den Proxy

# Nur der ProxyHost 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 NPMWebinterface → Proxy Hosts → Add Proxy Host:

Feld Wert
Domain Names serienbrief.deine-domain.lan
Scheme http
Forward Hostname / IP LANIP des DockerHosts (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 HTTPSRedirects laufen in eine Schleife.

Tab SSL: Zertifikat zuweisen (Let's Encrypt via DNSChallenge 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 LANIP setzen, docker compose up -d
Timeout UFW/iptables blockt Regel ergänzen
502 ohne ConnectFehler falsche UpstreamIP im Proxy ProxyKonfig 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 CustomFormat)
  • media_YYYYMMDD_HHMMSS.tar.gz

5.2 RestoreTest auf WegwerfContainer

# WegwerfPostgres
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 BackupRetention prüfen

docker-compose.yml: backupService hat Default 14 Tage. Bei JOB_RETENTION_DAYS=30 ist das knapp — BackupRetention sollte >= JOB_RETENTION_DAYS + ein paar Tage sein, sonst gehen DSGVOrelevante AuditSpuren verloren, bevor du sie aus dem Backup wiederherstellen könntest.

Anpassen über die BACKUP_RETENTION_DAYSVariable (falls vorgesehen) oder direkt im ServiceSkript.

5.4 OffSiteKopie

Backups liegen aktuell nur auf der HostDisk. 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: HostHardening

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 iptablesRegeln in der DOCKER-USERChain schreibt. Solange du APP_BIND_IP auf eine konkrete LANIP (nicht 0.0.0.0) setzt, ist der Port nur auf diesem Interface offen und UFW greift zuverlässig.

6.2 SSHHardening

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: SSHKey des AdminWorkplatzes in ~/.ssh/authorized_keys hinterlegen und in einer zweiten SSHSession 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 DockerStackRestart sonst zur Unzeit passiert.

6.5 AuditLogging

sudo apt install -y auditd
# Default-Regeln reichen für Standard-Compliance.
sudo systemctl enable --now auditd

7. Pflicht: SmokeTest

Vor dem offiziellen GoLive: einmal vollständig durchklicken.

7.1 Funktionaler SmokeTest

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. JobForm /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 OutputPDF PDF lädt herunter
8. Logout LogoutButton redirect zu Login
9. Lockout 5× falsches Passwort django-axes lockt aus

7.2 SicherheitsSmokeTest

# 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 TestSuite im Container

docker compose -f docker-compose.yml exec web pytest mailmerge/tests/ -v
# erwartet: alle Tests grün

8. Empfehlung: Betriebsautomatisierung

8.1 systemdUnit für den Stack

Damit der Stack nach HostReboot deterministisch startet (vor allem nach KernelUpdates). 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.yml ohne Override — verhindert, dass nach einem Reboot das DevOverride greift.

8.2 HealthWatchdog (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 MailSetup (msmtp, postfix, etc.) oder Anbindung an Uptime Kuma / Healthchecks.io.

8.3 Tägliche JobStatistik (optional)

CronEintrag, der einmal täglich eine StatusMail 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 LogRotation des DockerDaemons

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: journald als LogDriver, dann via rsyslog/vector/promtail weiterreichen
  • 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 ContainerMetriken; DjangoPrometheus für Appinterne Counter (JobAnzahl, RenderDauer).


10. Empfehlung: ImageScanning

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 pipaudit für PythonDependencies

docker compose -f docker-compose.yml run --rm web pip-audit

In requirements-dev.txt aufnehmen, damit es im DevImage vorhanden ist.

10.3 BuildPipeline (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 atrest

Backups, media_files und postgres_data liegen aktuell unverschlüsselt auf der HostDisk. Bei HardwareDiebstahl / Wiederverwendung sind personenbezogene Daten lesbar.

11.1 LUKS auf der DatenPartition

Auf dem Host vor der Erstinstallation einrichten. Nachträglich nur mit DatenMigration 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 PassphrasePrompt
  • RecoveryKey in Tresor

11.2 Alternative: FilesystemEncryption

  • ZFS mit nativer Encryption (zfs set encryption=on …)
  • ext4 + fscrypt (PerDirectoryEncryption)

11.3 BackupVerschlüsselung

Falls die DatenPartition 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ängerKey sicher aufbewahrt, nicht auf demselben Host.


12. Optional: AppArmorProfile

DefaultDockerAppArmorProfil 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 ContainerCrashes.


13. DatenschutzDokumentation

Vor Echtbetrieb folgende Dokumente aktualisieren:

13.1 VVTEintrag (Verzeichnis von Verarbeitungstätigkeiten)

Feld Beispielinhalt
Bezeichnung SerienbriefSystem
Zweck Erzeugung personalisierter Anschreiben aus DOCXVorlagen
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. (usecaseabhä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 Technischorganisatorische Maßnahmen (TOMs)

Schutzziel Maßnahme
Vertraulichkeit TLS am externen Proxy, Auth via Django + djangoaxes, Container nonroot
Integrität CSRF, signed Cookies, AuditLog pro Job
Verfügbarkeit tägliches Backup + verifizierter Restore, Healthchecks
Belastbarkeit ComposeRestartPolicy, systemdUnit, ResourceLimits
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 DSFATrigger

Eine DatenschutzFolgenabschä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 RetentionCleanup hinterlässt keinen persistenten AuditEintrag, weil der Job selbst gelöscht wird. Falls Nachweispflicht: BeatLogs zentralisieren und mindestens JOB_RETENTION_DAYS + Aufbewahrungsfrist Log aufbewahren.


14. Update & Rollback

14.1 StandardUpdate

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>

BackwardsCompatibility der Migrationen ist kein Selbstläufer. Vor Schema‑Änderungen, die nicht reversibel sind, immer vorher pg_dump erstellen und im Notfall einspielen.


15. PreGoLiveCheckliste

Abhaken vor dem offiziellen GoLive:

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://<produktive-domain>
  • APP_BIND_IP auf LANIP gesetzt (nicht 127.0.0.1, falls Proxy extern)
  • APP_UID=1000 / APP_GID=1000 (an HostUser)
  • JOB_RETENTION_DAYS an Geschäftsanforderung angepasst
  • PostgresPasswort in .env und SecretFile identisch

StackLauf

  • docker compose -f docker-compose.yml ps zeigt alle Services healthy
  • Periodic Task Retention-Cleanup in Admin sichtbar und enabled
  • Superuser angelegt, starkes Passwort
  • TestSuite läuft grün

ReverseProxy

  • TLSZertifikat gültig
  • X-Forwarded-Proto $scheme gesetzt
  • client_max_body_size >= 50M
  • Externe URL liefert LoginSeite (kein 502)
  • HSTS am externen Proxy aktiv

Host

  • UFW aktiv, nur 22 + 8080 (vom Proxy) erlaubt
  • SSH: keine PasswortAuth, kein Root, AllowGroups gesetzt
  • fail2ban läuft
  • UnattendedUpgrades aktiv
  • systemdUnit serienbrief.service enabled

Backup

  • BackupService läuft, schreibt Dumps
  • RestoreTest auf WegwerfContainer erfolgreich
  • OffSiteKopie eingerichtet (cron auf zweiten Server)
  • BackupRetention >= JOB_RETENTION_DAYS + Sicherheitspuffer

Monitoring

  • HealthCheckCron läuft, TestAlarm verifiziert
  • LogDriver konfiguriert (Rotation oder zentral)

SicherheitsSmokeTest

  • PostgresPort 5432 nicht von außen erreichbar
  • RedisPort 6379 nicht von außen erreichbar
  • Direkter Zugriff auf 8080 nur vom Proxy möglich
  • 5 Fehlversuche → djangoaxes Lockout funktioniert
  • CSRF wird erzwungen

Funktionaler SmokeTest

  • Template hochladen
  • CSV hochladen, Preview erzeugt PDF inline
  • Vollständiger Job läuft durch, PDF downloadbar
  • Logout funktioniert
  • RetentionCleanup im DryRun liefert plausibles Ergebnis

Dokumentation

  • VVTEintrag aktualisiert
  • TOMs dokumentiert
  • DSFAPflicht bewertet (ggf. durchgeführt)
  • NotfallKontakte hinterlegt
  • RestoreProzedur dokumentiert und im Notfallhandbuch verlinkt

Anhang: Häufige Fehler & Fixes

Symptom Ursache Fix
permission denied bei makemigrations UIDMismatch 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 LANIP setzen, UFWRegel
CSRF verification failed beim Login Domain fehlt in CSRF_TRUSTED_ORIGINS .env ergänzen, restart web
Logout liefert 405 Method Not Allowed GETLogout in Django 5 abgeschafft POSTForm (bereits in base.html)
network ... not found beim ComposeStart docker compose -f docker-compose.yml ohne Override → fehlende Bridge Override behalten oder Netzwerk in docker-compose.yml definieren
PreviewPDF leer/kaputt LibreOfficeUserProfil 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 DBZeilen) Bulkqs.delete() ohne FileFieldHooks Aktuelle retention.py iteriert einzeln — CodeStand verifizieren

Verantwortlich: Datenschutzkoordination / ITSystemadministration Letzte Überprüfung: Datum hier eintragen Nächste Überprüfung: spätestens nach MajorUpdate oder einmal jährlich