Deployment anleitung
This commit is contained in:
+944
@@ -0,0 +1,944 @@
|
||||
# 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_
|
||||
@@ -43,6 +43,7 @@ Zielumgebung: internes LAN, hinter zentralem Reverse‑Proxy (TLS‑Terminierung
|
||||
- Audit‑Log pro Job (Status‑Wechsel, Fehler, abgearbeitete Zeilen)
|
||||
- Tägliches Backup (pg_dump + Media‑Tar, 14 Tage Retention)
|
||||
- **Test‑Suite** mit pytest‑django (Service‑Unit, View‑Layer, Integration mit echtem LibreOffice) — siehe [TEST.md](TEST.md)
|
||||
- **Production‑Anleitung** mit Go‑Live‑Checkliste — siehe [DEPLOYMENT.md](DEPLOYMENT.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user