945 lines
29 KiB
Markdown
945 lines
29 KiB
Markdown
|
|
# 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 <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
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**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 <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 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://<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` | 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 <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 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://<docker-host-ip>:443/ # ConnectionRefused erwartet, weil 443 nicht offen
|
|||
|
|
|
|||
|
|
# Direkt auf 8080 von einem Nicht-Proxy-Host — muss blockiert sein
|
|||
|
|
curl -v http://<docker-host-ip>:8080/ # Timeout/ConnectionRefused erwartet
|
|||
|
|
|
|||
|
|
# Postgres / Redis dürfen NICHT von außen erreichbar sein
|
|||
|
|
nc -zv <docker-host-ip> 5432 # erwartet: closed
|
|||
|
|
nc -zv <docker-host-ip> 6379 # erwartet: closed
|
|||
|
|
|
|||
|
|
# CSRF-Token Pflicht
|
|||
|
|
curl -k -X POST https://serienbrief.deine-domain.lan/accounts/login/ \
|
|||
|
|
-d "username=admin&password=test"
|
|||
|
|
# erwartet: 403 (CSRF verification failed)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 Test‑Suite im Container
|
|||
|
|
|
|||
|
|
```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 <tunables/global>
|
|||
|
|
|
|||
|
|
profile docker-serienbrief flags=(attach_disconnected,mediate_deleted) {
|
|||
|
|
#include <abstractions/base>
|
|||
|
|
#include <abstractions/python>
|
|||
|
|
|
|||
|
|
/app/** rk,
|
|||
|
|
/tmp/** rwk,
|
|||
|
|
/usr/local/lib/python3.12/** rk,
|
|||
|
|
/usr/lib/libreoffice/** rk,
|
|||
|
|
|
|||
|
|
deny capability sys_admin,
|
|||
|
|
deny capability sys_module,
|
|||
|
|
deny capability sys_ptrace,
|
|||
|
|
deny mount,
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> Erst im **Staging** testen, falsche Pfade führen sofort zu Container‑Crashes.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 13. Datenschutz‑Dokumentation
|
|||
|
|
|
|||
|
|
Vor Echtbetrieb folgende Dokumente aktualisieren:
|
|||
|
|
|
|||
|
|
### 13.1 VVT‑Eintrag (Verzeichnis von Verarbeitungstätigkeiten)
|
|||
|
|
|
|||
|
|
| Feld | Beispielinhalt |
|
|||
|
|
|---|---|
|
|||
|
|
| Bezeichnung | Serienbrief‑System |
|
|||
|
|
| Zweck | Erzeugung personalisierter Anschreiben aus DOCX‑Vorlagen |
|
|||
|
|
| Rechtsgrundlage | Art. 6 Abs. 1 lit. b/c/e DSGVO (anwendungsabhängig) |
|
|||
|
|
| Datenkategorien | Anrede, Vor‑/Nachname, Anschrift, ggf. Personalnummer, Funktion |
|
|||
|
|
| Betroffene | Mitarbeiter:innen / Kunden / etc. (use‑case‑abhängig) |
|
|||
|
|
| Empfänger | Postlogistik (sofern PDF gedruckt/versandt) |
|
|||
|
|
| Aufbewahrung | 30 Tage (`JOB_RETENTION_DAYS`), automatische Löschung |
|
|||
|
|
| Drittstaaten | nein |
|
|||
|
|
| TOMs | siehe 13.2 |
|
|||
|
|
|
|||
|
|
### 13.2 Technisch‑organisatorische Maßnahmen (TOMs)
|
|||
|
|
|
|||
|
|
| Schutzziel | Maßnahme |
|
|||
|
|
|---|---|
|
|||
|
|
| Vertraulichkeit | TLS am externen Proxy, Auth via Django + django‑axes, Container non‑root |
|
|||
|
|
| Integrität | CSRF, signed Cookies, Audit‑Log pro Job |
|
|||
|
|
| Verfügbarkeit | tägliches Backup + verifizierter Restore, Healthchecks |
|
|||
|
|
| Belastbarkeit | Compose‑Restart‑Policy, systemd‑Unit, Resource‑Limits |
|
|||
|
|
| Wiederherstellbarkeit | pg_restore aus Backup, dokumentiert |
|
|||
|
|
| Eingabekontrolle | `JobLogEntry` protokolliert `created_by`, Zeitstempel, Statuswechsel |
|
|||
|
|
| Auftragskontrolle | n/a (kein Auftragsverarbeiter) |
|
|||
|
|
| Trennungskontrolle | Mandanten‑/Rollenmodell ⚠️ noch offen — siehe Roadmap |
|
|||
|
|
| Löschung | `mailmerge.cleanup_expired_jobs` täglich, manuell per `cleanup_jobs` Command |
|
|||
|
|
|
|||
|
|
### 13.3 DSFA‑Trigger
|
|||
|
|
|
|||
|
|
Eine Datenschutz‑Folgenabschätzung gemäß Art. 35 DSGVO ist erforderlich, wenn:
|
|||
|
|
|
|||
|
|
- besondere Datenkategorien (Art. 9) verarbeitet werden (Gesundheitsdaten etc.)
|
|||
|
|
- umfangreiche Profilbildung erfolgt
|
|||
|
|
- systematische Überwachung großer Personenmengen stattfindet
|
|||
|
|
|
|||
|
|
→ Vor Echtbetrieb durch DSB/DPO bewerten.
|
|||
|
|
|
|||
|
|
### 13.4 Löschnachweis
|
|||
|
|
|
|||
|
|
Der Retention‑Cleanup hinterlässt **keinen** persistenten Audit‑Eintrag, weil der Job selbst gelöscht wird. Falls Nachweispflicht: Beat‑Logs zentralisieren und mindestens `JOB_RETENTION_DAYS + Aufbewahrungsfrist Log` aufbewahren.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 14. Update & Rollback
|
|||
|
|
|
|||
|
|
### 14.1 Standard‑Update
|
|||
|
|
|
|||
|
|
```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>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> 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://<produktive-domain>`
|
|||
|
|
- [ ] `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_
|