Files
serienbrief_django/DEPLOYMENT.md
T
2026-05-22 09:15:59 +02:00

945 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#1-vorbereitung)
2. [Pflicht: Konfiguration](#2-pflicht-konfiguration)
3. [Pflicht: Erststart in Produktion](#3-pflicht-erststart-in-produktion)
4. [Pflicht: ReverseProxy](#4-pflicht-reverseproxy)
5. [Pflicht: Backup verifizieren](#5-pflicht-backup-verifizieren)
6. [Pflicht: HostHardening](#6-pflicht-hosthardening)
7. [Pflicht: SmokeTest](#7-pflicht-smoketest)
8. [Empfehlung: Betriebsautomatisierung](#8-empfehlung-betriebsautomatisierung)
9. [Empfehlung: Monitoring & Logging](#9-empfehlung-monitoring--logging)
10. [Empfehlung: ImageScanning](#10-empfehlung-imagescanning)
11. [Optional: Verschlüsselung atrest](#11-optional-verschl%C3%BCsselung-atrest)
12. [Optional: AppArmorProfile](#12-optional-apparmorprofile)
13. [DatenschutzDokumentation](#13-datenschutzdokumentation)
14. [Update & Rollback](#14-update--rollback)
15. [PreGoLiveCheckliste](#15-pregolivecheckliste)
---
## 1. Vorbereitung
### Voraussetzungen am Host
```bash
# 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
```bash
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
```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
```
**RedisPasswort** (hex statt base64 → keine URLParsingProbleme):
```bash
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:
```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 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:
```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 HostUser anpassen (sonst Permissiondenied auf Volumes/BindMounts)
APP_UID=1000
APP_GID=1000
JOB_RETENTION_DAYS=30
```
LANIP 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 RedisPasswort (nicht `${REDIS_PASSWORD}`)
- `DATABASE_URL` enthält das echte PostgresPasswort
---
## 3. Pflicht: Erststart in Produktion
### 3.1 Image bauen
```bash
docker compose build --pull
```
`--pull` holt das aktuellste BaseImage (Sicherheitsfixes).
### 3.2 Stack starten — **explizit ohne DevOverride**
```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 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
```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 (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
```bash
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
```bash
# 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](#61-firewall-ufw).
### 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**:
```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 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
```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
```bash
# 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
```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 CustomFormat)
- `media_YYYYMMDD_HHMMSS.tar.gz`
### 5.2 RestoreTest auf WegwerfContainer
```bash
# 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`: `backup`Service 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_DAYS`Variable (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.
```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: HostHardening
### 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 <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 der `DOCKER-USER`Chain 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
```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**: SSHKey des AdminWorkplatzes in `~/.ssh/authorized_keys` hinterlegen und in einer **zweiten** SSHSession 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 DockerStackRestart sonst zur Unzeit passiert.
### 6.5 AuditLogging
```bash
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
```bash
# 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
```bash
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`:
```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 DevOverride greift.
### 8.2 HealthWatchdog (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 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:
```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 LogRotation des DockerDaemons
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 LogDriver, 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 ContainerMetriken; DjangoPrometheus für Appinterne Counter (JobAnzahl, RenderDauer).
---
## 10. Empfehlung: ImageScanning
### 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 pipaudit für PythonDependencies
```bash
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`:
```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 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:
```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ä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`):
```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 <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
```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 <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) | Bulk`qs.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_