Files
serienbrief_django/DEPLOYMENT.md
T

945 lines
29 KiB
Markdown
Raw Normal View History

2026-05-22 09:15:59 +02:00
# 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_