Files
betreuungs-cockpit/docs/INSTALLATION.md
T
Florian Wilde c35fc16133 docs: Installationsanleitung fuer vorhandenes Docker und externen Proxy
Die Anleitung setzt jetzt voraus, was ohnehin da ist: ein Server mit
laufendem Docker und ein Reverse Proxy, der schon steht - auch auf einem
anderen Rechner. Docker-Installation und Betriebssystemhaertung sind
raus, dafuer ist der Proxy-Abschnitt der ausfuehrlichste geworden.

Damit ein externer Proxy die Anwendung ueberhaupt erreicht, war eine
Aenderung noetig: Die Bindeadresse stand fest auf 127.0.0.1. Jetzt gibt
es APP_BIND, Standard bleibt die Loopback-Adresse.

Der Weg dorthin ist nach Vorzug sortiert - privates Netz, dann
WireGuard, dann als Notloesung oeffentliche Adresse mit Firewallregel.
Zwischen Proxy und Anwendung laeuft unverschluesseltes HTTP; das gehoert
nicht ins offene Netz. Deshalb steht in der Kontrolle eine Gegenprobe,
die fehlschlagen MUSS: Antwortet der Anwendungsport von aussen, ist die
Firewallregel falsch.

Fertige Konfigurationen fuer Caddy, nginx und Traefik, jeweils mit den
Werten, die sonst uebersehen werden:

- Zeitgrenze 10 Minuten - sonst brechen Belegmappe, Rechnungslegung und
  Vollexport mitten im Erzeugen ab. Die Standardwerte von nginx liegen
  bei 60 Sekunden.
- Antwort nicht puffern - der Vollexport kommt als Strom. Puffert der
  Proxy, wartet der Browser, bis alles im Speicher des Proxys liegt, und
  laeuft dabei in die Zeitgrenze.
- Und der Hinweis, dass der Proxy KEINE eigene CSP setzen darf: Sie
  enthaelt eine Nonce, die pro Anfrage neu ist. Eine feste CSP blockiert
  die eigenen Skripte - die Seite laedt dann, laesst sich aber nicht
  bedienen.

Der A-Record zeigt auf den Proxy-Server, nicht auf den Anwendungsserver.
Stand vorher falsch da.

Etappe: 8
Phase: Haertung
2026-08-22 11:00:29 +02:00

917 lines
27 KiB
Markdown

# Installation Schritt für Schritt
Von einem vorbereiteten Server bis zur laufenden Anwendung. Rechne mit
**30 bis 45 Minuten**, wenn du es zum ersten Mal machst.
Diese Anleitung setzt einen Server voraus, auf dem **Docker bereits läuft**,
und einen **Reverse Proxy, der schon steht** — auch dann, wenn er auf einem
anderen Server liegt. Sie richtet nur das Betreuungs-Cockpit ein: kein
Docker, keine Betriebssystemhärtung, kein Proxy von Grund auf.
Was genau auf dem Proxy hinterlegt werden muss, steht vollständig in
[Schritt 7](#schritt-7--in-den-reverse-proxy-eintragen).
Nach jedem Schritt steht eine **Kontrolle**. Geh nicht weiter, bevor die
durchgeht — dann weißt du im Fehlerfall genau, wo es hakt.
> Wenn du die Anwendung nur ausprobieren willst, ohne Server: Der
> Abschnitt *Schnellstart* in der [README](../README.md) bringt dich in
> zehn Minuten auf dem eigenen Rechner zum Laufen.
---
## Inhalt
- [Schritt 0 — Voraussetzungen prüfen](#schritt-0--voraussetzungen-prüfen)
- [Schritt 1 — Anwendung holen](#schritt-1--anwendung-holen)
- [Schritt 2 — Schlüssel erzeugen](#schritt-2--schlüssel-erzeugen)
- [Schritt 3 — Konfiguration ausfüllen](#schritt-3--konfiguration-ausfüllen)
- [Schritt 4 — Starten](#schritt-4--starten)
- [Schritt 5 — Datenbank vorbereiten](#schritt-5--datenbank-vorbereiten)
- [Schritt 6 — Dateiablage einrichten](#schritt-6--dateiablage-einrichten)
- [Schritt 7 — In den Reverse Proxy eintragen](#schritt-7--in-den-reverse-proxy-eintragen)
- [Schritt 8 — Ersteinrichtung im Browser](#schritt-8--ersteinrichtung-im-browser)
- [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen)
- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten)
- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen)
- [Aktualisieren](#aktualisieren)
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
- [Abnahmeliste](#abnahmeliste)
---
## Schritt 0 — Voraussetzungen prüfen
### Was der Server mitbringen muss
| | Minimum | Bequem |
|---|---|---|
| Arbeitsspeicher | 2 GB frei | 4 GB frei |
| Speicherplatz | 20 GB frei | 40 GB frei |
| Docker | 24 oder neuer | aktuell |
| Docker Compose | v2 (`docker compose`, nicht `docker-compose`) | aktuell |
| Reverse Proxy | vorhanden, mit TLS (auch auf einem anderen Server) | dasselbe |
| Netzweg zum Proxy | privates Netz, WireGuard oder Firewallregel | privates Netz |
| Standort | Deutschland oder mindestens EU | dasselbe |
Der erste Bau lädt Tesseract mit den deutschen Sprachdaten — plan etwa
**1 GB Netzverkehr** und fünf bis zehn Minuten ein.
### Kontrolle
Melde dich per SSH auf dem Server an und prüf:
```bash
docker --version # 24.x oder neuer
docker compose version # v2.x - mit Leerzeichen, nicht docker-compose
docker run --rm hello-world # "Hello from Docker!"
df -h / # genug Platz?
free -h # genug Speicher?
```
Kommt bei `docker run` ein *permission denied*, fehlt dir die
Docker-Gruppe:
```bash
sudo usermod -aG docker $USER
```
Danach **abmelden und neu anmelden**, sonst greift es nicht.
### Zeitzone
Die Behälter setzen `Europe/Berlin` selbst — der Host muss also nicht
umgestellt sein. Für die Logs und den cron-Eintrag aus Schritt 10 ist es
trotzdem angenehm:
```bash
timedatectl | grep "Time zone"
# nicht Europe/Berlin? dann:
sudo timedatectl set-timezone Europe/Berlin
```
### Domain
Du brauchst eine Adresse, unter der die Anwendung erreichbar sein soll,
zum Beispiel `cockpit.deine-domain.de`.
Der A-Record zeigt auf den **Proxy-Server**, nicht auf diesen hier — der
Proxy nimmt die Anfragen entgegen und reicht sie weiter. Setz ihn
**jetzt**, DNS-Änderungen brauchen manchmal eine Stunde.
```bash
dig +short cockpit.deine-domain.de # muss die IP des PROXY-Servers zeigen
```
### Und das Wichtigste
> Kläre den **Auftragsverarbeitungsvertrag nach Art. 28 DSGVO** mit deinem
> Hoster. Bei Hetzner im Kundenkonto unter *Verträge → AV-Vertrag*, bei
> anderen ähnlich. Ohne ihn ist der Betrieb formal rechtswidrig — und es
> dauert fünf Minuten.
>
> Dasselbe gilt für den Objektspeicher, falls du einen externen benutzt
> statt des mitgelieferten MinIO.
Halt außerdem etwas bereit, um Schlüssel zu notieren. Ein Passwortmanager
ist das Richtige — du erzeugst gleich Werte, die du nicht verlieren darfst.
---
## Schritt 1 — Anwendung holen
```bash
sudo mkdir -p /opt/betreuungs-cockpit
sudo chown $USER:$USER /opt/betreuungs-cockpit
git clone https://git.wilde-server.de/florian.wilde/betreuungs-cockpit.git \
/opt/betreuungs-cockpit
cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand
git checkout v1.0.0
```
### Kontrolle
```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.0.0
```
---
## Schritt 2 — Schlüssel erzeugen
Die Anwendung braucht sieben Geheimnisse. Erzeuge sie **auf dem Server**,
nicht auf dem eigenen Rechner:
```bash
docker run --rm node:22-alpine node -e "
const c = require('crypto');
const b = n => c.randomBytes(n).toString('base64');
const w = n => b(n).replace(/[+/=]/g,'x');
console.log('AUTH_SECRET=' + b(32));
console.log('ENC_KEY_V1=' + b(32));
console.log('INDEX_KEY=' + b(32));
console.log('POSTGRES_PASSWORD=' + w(24));
console.log('SICHERUNG_PASSWORT=' + w(24));
console.log('S3_ACCESS_KEY_ID=' + w(12));
console.log('S3_SECRET_ACCESS_KEY=' + w(24));
"
```
Kopier die Ausgabe in einen Texteditor. Du brauchst sie im nächsten
Schritt.
> **`ENC_KEY_V1` ist der wichtigste Wert der ganzen Installation.** Mit ihm
> sind Diagnosen, IBANs, Versicherungsnummern und alle Notizen
> verschlüsselt. Geht er verloren, sind diese Felder
> **unwiederbringlich** weg — auch aus jedem Backup. Es gibt keine
> Hintertür.
>
> Schritt 11 sagt dir, wohin damit. Verlier ihn bis dahin nicht.
---
## Schritt 3 — Konfiguration ausfüllen
```bash
cp .env.example .env
nano .env
```
Diese Werte **musst** du setzen:
```bash
# --- Datenbank ---
POSTGRES_PASSWORD=<aus Schritt 2>
# --- Anmeldung ---
AUTH_SECRET=<aus Schritt 2>
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
AUTH_TRUST_HOST=true
# --- Verschlüsselung ---
ENC_KEY_V1=<aus Schritt 2>
ENC_KEY_AKTIV=1
INDEX_KEY=<aus Schritt 2>
# --- Objektspeicher ---
S3_ACCESS_KEY_ID=<aus Schritt 2>
S3_SECRET_ACCESS_KEY=<aus Schritt 2>
# --- Sicherung ---
SICHERUNG_PASSWORT=<aus Schritt 2>
```
> **`AUTH_URL` muss exakt der Adresse entsprechen**, unter der du die
> Anwendung später aufrufst — mit `https://`, ohne Schrägstrich am Ende.
> Steht dort etwas anderes, wird das Sitzungs-Cookie für die falsche
> Herkunft gesetzt: Die Anmeldung landet stumm wieder auf dem Formular,
> ohne Fehlermeldung. Das ist der Fehler, den man am längsten sucht.
**Läuft auf dem Server schon etwas auf Port 3000?** Dann weich aus:
```bash
APP_PORT=3010
```
Auf welcher Adresse die Anwendung horcht, legst du in
[Schritt 7](#schritt-7--in-den-reverse-proxy-eintragen) fest — dort steht
auch, warum `127.0.0.1` nicht reicht, wenn der Proxy woanders läuft.
Optional, aber empfohlen — Fristerinnerungen per E-Mail:
```bash
SMTP_HOST=mail.deine-domain.de
SMTP_PORT=587
SMTP_USER=cockpit@deine-domain.de
SMTP_PASSWORD=<dein Mailpasswort>
SMTP_FROM="Betreuungs-Cockpit <cockpit@deine-domain.de>"
```
Speichern mit `Strg+O`, `Enter`, `Strg+X`. Dann die Datei absichern — sie
enthält alle Schlüssel:
```bash
chmod 600 .env
```
### Kontrolle
```bash
# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \
| sed 's/=.*/ = gesetzt/'
```
Acht Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
und sagt dir beim Start auch, welche.
---
## Schritt 4 — Starten
```bash
cd /opt/betreuungs-cockpit
# Git-Stand mitgeben, damit später sichtbar ist, was läuft
GIT_COMMIT=$(git rev-parse --short HEAD) \
BUILD_ZEIT=$(date -Iseconds) \
docker compose up -d --build
```
Der erste Bau dauert **fünf bis zehn Minuten**. Danach geht es schnell.
### Kontrolle
```bash
docker compose ps
```
Vier Dienste, alle `running`, Datenbank und Anwendung `healthy`:
```
NAME STATUS
betreuungs-cockpit-app-1 Up (healthy)
betreuungs-cockpit-db-1 Up (healthy)
betreuungs-cockpit-minio-1 Up (healthy)
betreuungs-cockpit-sicherung-1 Up
```
```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.0.0","commit":"..."}
```
Kommt hier nichts, sieh in die Logs:
```bash
docker compose logs app --tail 50
```
---
## Schritt 5 — Datenbank vorbereiten
Die **Migrationen laufen beim Start automatisch mit**. Zwei SQL-Skripte
kommen einmalig von Hand dazu — sie richten die Volltextsuche ein und
machen das Audit-Log unveränderlich:
```bash
docker compose exec app node node_modules/prisma/build/index.js db execute \
--schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql
docker compose exec app node node_modules/prisma/build/index.js db execute \
--schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql
```
### Kontrolle
Prüf, dass der Schutz des Audit-Logs wirklich greift. Der folgende Befehl
**muss** mit einem Fehler enden:
```bash
docker compose exec db psql -U cockpit betreuungs_cockpit \
-c "UPDATE audit_log SET entitaet='x';"
```
Erwartete Ausgabe:
```
ERROR: Das Audit-Log ist unveraenderlich. UPDATE auf audit_log ist nicht zulaessig.
```
Kommt stattdessen `UPDATE 0`, ist das Skript nicht gelaufen. Führ es
erneut aus — beide Skripte sind so gebaut, dass mehrfaches Ausführen
nichts kaputtmacht.
---
## Schritt 6 — Dateiablage einrichten
Der Objektspeicher braucht einen Ablagebereich für die Dokumente:
```bash
set -a && source .env && set +a
docker compose exec minio mc alias set lokal \
http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY"
docker compose exec minio mc mb lokal/betreuungs-cockpit
```
### Kontrolle
```bash
docker compose exec minio mc ls lokal
# [...] betreuungs-cockpit/
```
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
> `minio`-Dienst aus der `docker-compose.yml`, entfern ihn aus
> `depends_on` beim `app`-Dienst und zeig mit `S3_ENDPOINT` dorthin. Den
> Bucket legst du dann beim Anbieter an. **Auch dafür brauchst du einen
> AV-Vertrag.**
---
## Schritt 7 — In den Reverse Proxy eintragen
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
tun: die Anwendung für ihn erreichbar machen, und ihn richtig
konfigurieren.
### 7a — Erreichbar machen, aber nur für den Proxy
Standardmäßig horcht die Anwendung auf `127.0.0.1` — von außen kommt dann
niemand heran, auch der Proxy nicht. Das musst du ändern.
> **Die Anwendung beendet kein TLS.** Zwischen Proxy und Anwendung läuft
> unverschlüsseltes HTTP. Dieser Weg darf niemals durchs offene Netz
> gehen. Es gibt drei Möglichkeiten, in dieser Reihenfolge:
**Erste Wahl: privates Netz.** Hetzner, netcup und die meisten anderen
bieten ein privates Netz zwischen deinen Servern an. Trag in die `.env`
die Adresse ein, die dieser Server dort hat:
```bash
APP_BIND=10.0.0.5 # die private Adresse DIESES Servers
APP_PORT=3000
```
**Zweite Wahl: WireGuard.** Gibt es kein privates Netz, bau einen Tunnel
zwischen beiden Servern und trag die Tunneladresse ein:
```bash
APP_BIND=10.8.0.2
```
**Notlösung: öffentliche Adresse plus Firewall.** Nur, wenn beides nicht
geht — und dann zwingend mit einer Regel, die ausschließlich die IP des
Proxy-Servers durchlässt:
```bash
# in der .env
APP_BIND=0.0.0.0
# und auf DIESEM Server, vor dem Neustart:
sudo ufw allow from <IP-des-Proxy-Servers> to any port 3000 proto tcp
sudo ufw deny 3000/tcp
```
Prüf danach von einem dritten Rechner aus, dass der Port wirklich zu ist.
Vergiss die Regel, und deine Anwendung steht unverschlüsselt im Netz.
Nach der Änderung:
```bash
docker compose up -d
docker compose ps # unter PORTS muss die neue Adresse stehen
```
### 7b — Was auf dem Proxy-Server hinterlegt wird
Das ist der Teil, den du auf dem **anderen** Server einträgst. Ersetz
`10.0.0.5` durch die Adresse aus 7a.
#### Caddy
```caddyfile
cockpit.deine-domain.de {
encode zstd gzip
reverse_proxy 10.0.0.5:3000 {
# Berichte und der Vollexport brauchen Zeit. Ohne diese Werte
# bricht der Proxy mitten im Erzeugen ab.
transport http {
read_timeout 10m
write_timeout 10m
}
# Der Vollexport kommt als Strom - nicht puffern, sonst wartet der
# Browser, bis alles im Speicher des Proxys liegt.
flush_interval -1
}
# Ein Handyfoto eines mehrseitigen Bescheids ist schnell 10 MB.
request_body {
max_size 30MB
}
}
```
Caddy setzt `Host`, `X-Forwarded-For` und `X-Forwarded-Proto` von selbst
richtig — dafür musst du nichts tun.
#### nginx
```nginx
server {
listen 443 ssl;
http2 on;
server_name cockpit.deine-domain.de;
ssl_certificate /etc/letsencrypt/live/cockpit.deine-domain.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cockpit.deine-domain.de/privkey.pem;
# Uploads von Behördenschreiben
client_max_body_size 30M;
location / {
proxy_pass http://10.0.0.5:3000;
proxy_http_version 1.1;
# Ohne diese vier Zeilen funktioniert die Anwendung nicht richtig,
# siehe Tabelle unter 7c.
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 $scheme;
# PDF-Erzeugung und Vollexport dauern länger als die üblichen 60 s.
proxy_connect_timeout 30s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
# Der Vollexport kommt als Strom. Ohne diese beiden Zeilen sammelt
# nginx erst alles ein, und der Browser sieht minutenlang nichts.
proxy_buffering off;
proxy_request_buffering off;
}
}
# Alles Unverschlüsselte umleiten
server {
listen 80;
server_name cockpit.deine-domain.de;
return 301 https://$host$request_uri;
}
```
Aktivieren:
```bash
sudo ln -s /etc/nginx/sites-available/cockpit /etc/nginx/sites-enabled/
sudo certbot --nginx -d cockpit.deine-domain.de
sudo nginx -t && sudo systemctl reload nginx
```
#### Traefik
Weil die Anwendung auf einem anderen Server läuft, geht es **nicht** über
Docker-Labels, sondern über eine dynamische Konfigurationsdatei:
```yaml
http:
routers:
cockpit:
rule: "Host(`cockpit.deine-domain.de`)"
service: cockpit
entryPoints: [websecure]
tls:
certResolver: letsencrypt
services:
cockpit:
loadBalancer:
servers:
- url: "http://10.0.0.5:3000"
# Vollexport als Strom ausliefern, nicht sammeln
responseForwarding:
flushInterval: -1
```
Dazu in der statischen Konfiguration großzügige Zeitgrenzen:
```yaml
entryPoints:
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: 600s
writeTimeout: 600s
idleTimeout: 180s
```
### 7c — Was der Proxy leisten muss, und warum
| Anforderung | Was ohne sie passiert |
|---|---|
| **TLS beenden** | Der Browser lehnt Cookies mit `Secure`-Kennzeichen ab — die Anmeldung schlägt stumm fehl |
| **`Host` durchreichen** | Auth.js vergleicht den Host mit `AUTH_URL` und weist die Anmeldung ab |
| **`X-Forwarded-Proto: https`** | Die Anwendung baut Adressen mit `http` und setzt Cookies ohne `Secure` |
| **`X-Forwarded-For`** | Die Login-Sperre zählt alle Zugriffe als denselben Anschluss: Fünf Fehlversuche irgendwo sperren alle. Im Audit-Log fehlt die Herkunft |
| **Mindestens 30 MB Uploads** | Fotos mehrseitiger Bescheide werden abgeschnitten |
| **Zeitgrenze 10 Minuten** | Belegmappe, Rechnungslegung und Vollexport brechen mitten im Erzeugen ab |
| **Antwort nicht puffern** | Der Vollexport kommt als Strom. Puffert der Proxy, wartet der Browser, bis alles im Speicher des Proxys liegt — bei großen Beständen läuft er dabei in die Zeitgrenze |
**Keine eigenen Sicherheitskopfzeilen setzen.** CSP, `X-Frame-Options`,
`nosniff` und HSTS kommen aus der Anwendung. Besonders die CSP darf der
Proxy nicht überschreiben: Sie enthält eine Nonce, die pro Anfrage neu
ist. Eine feste CSP aus dem Proxy blockiert die eigenen Skripte — die
Seite lädt dann zwar, lässt sich aber nicht bedienen.
### Kontrolle
Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash
curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.0.0",...}
```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
Behälter wurde nach der Änderung nicht neu gestartet.
Dann **von deinem eigenen Rechner aus**:
```bash
curl -sI https://cockpit.deine-domain.de/anmelden | head -8
```
Erwartet: `HTTP/2 200` und die Kopfzeilen `content-security-policy`,
`x-frame-options: DENY`, `x-content-type-options: nosniff`,
`strict-transport-security`.
Und die Gegenprobe — der Port darf von außen **nicht** offen sein:
```bash
curl -s --max-time 5 http://<oeffentliche-IP-des-App-Servers>:3000/api/gesundheit
# muss in eine Zeitüberschreitung laufen oder abgewiesen werden
```
Antwortet er, ist deine Firewallregel falsch. Das ist kein
Schönheitsfehler: Über diesen Weg käme man unverschlüsselt an
Gesundheitsdaten.
## Schritt 8 — Ersteinrichtung im Browser
Ruf `https://cockpit.deine-domain.de` auf. Ein Assistent führt dich durch
drei Schritte: Zugang anlegen, zweiten Faktor einrichten, betreute Person
erfassen.
Die Einzelheiten stehen in der [Bedienungsanleitung](ANLEITUNG.md),
Abschnitt *Der erste Tag*. Drei Dinge sind hier wichtig:
1. **Die Seite `/ersteinrichtung` sperrt sich selbst**, sobald ein Zugang
existiert. Niemand kann sich danach von außen einen zweiten
Administrator anlegen.
2. **Die Wiederherstellungscodes werden genau einmal angezeigt.** Druck
sie aus, bevor du weiterklickst.
3. **Das Bestellungsdatum** muss stimmen — daraus entstehen alle
gesetzlichen Fristen.
Zum Schluss: **Fristen → Pflichtfristen ableiten**.
### Kontrolle
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.0.0 · <commit>`.
---
## Schritt 9 — Sicherung prüfen
Die tägliche Sicherung läuft ab jetzt um **03:15 Uhr**. Warte nicht bis
morgen — stoß sie einmal von Hand an:
```bash
cd /opt/betreuungs-cockpit
docker compose exec sicherung /bin/sh /sicherung.sh
```
Erwartete Ausgabe, in dieser Reihenfolge:
```
Sicherung beginnt: /backups/cockpit_2026-08-22_1430.dump.enc
Sicherung fertig (1.2M)
Selbstpruefung bestanden.
```
Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt
die eben geschriebene Datei sofort wieder. Schlägt das fehl, endet der
Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
### Und jetzt einmal zurückspielen
Ein Backup, das nie zurückgespielt wurde, ist kein Backup. Probier es
gleich aus, solange nichts drinsteht, was weh tut:
```bash
# Testdatenbank neben der echten anlegen
docker compose exec db createdb -U cockpit probe
# Zurückspielen (Dateinamen anpassen)
docker compose exec -e PGDATABASE=probe -e SICHERUNG_OHNE_RUECKFRAGE=ja \
sicherung /bin/sh /wiederherstellung.sh /backups/cockpit_<datum>.dump.enc
# Stichprobe
docker compose exec db psql -U cockpit probe \
-c "SELECT vorname, nachname FROM betreute;"
# Aufräumen
docker compose exec db dropdb -U cockpit probe
```
### Kontrolle
```bash
ls -lh backups/
# cockpit_....dump.enc und cockpit_....dump.enc.sha256
```
> **Was das Backup nicht enthält:** den Schlüssel `ENC_KEY_V1` und die
> Dateien im Objektspeicher. Für die Dateien:
>
> ```bash
> docker compose exec minio mc mirror lokal/betreuungs-cockpit /backups/dokumente
> ```
>
> Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup,
> das neben dem Original liegt, hilft bei einem Serverausfall nicht.
---
## Schritt 10 — Wartungslauf einrichten
Einmal täglich: Fristerinnerungen verschicken, Papierkorb leeren, alte
Anmeldeversuche entfernen.
```bash
# Schlüssel für den Endpunkt ausrechnen
docker compose exec app node -e "
console.log(require('crypto').createHmac('sha256', process.env.AUTH_SECRET)
.update('wartung').digest('base64url'))
"
```
Den ausgegebenen Wert in einen cron-Eintrag:
```bash
crontab -e
```
Diese Zeile ans Ende:
```cron
30 4 * * * curl -fsS -H "X-Wartung-Schluessel: <ausgegebener Wert>" https://cockpit.deine-domain.de/api/wartung > /dev/null
```
### Kontrolle
```bash
curl -s -H "X-Wartung-Schluessel: <wert>" \
https://cockpit.deine-domain.de/api/wartung
# {"erinnerungen":0,"papierkorbGeleert":0,...,"fehler":[]}
```
Ohne gültigen Schlüssel antwortet die Route mit **404**, nicht mit 401 —
wer die Adresse errät, soll nicht einmal erfahren, dass es sie gibt.
---
## Schritt 11 — Schlüssel in Sicherheit bringen
Der letzte Schritt, und der wichtigste.
**`ENC_KEY_V1` gehört an zwei Orte, keiner davon ist dieser Server:**
1. In deinen **Passwortmanager**.
2. **Ausgedruckt** zu deinen Betreuungsunterlagen, im Ordner beim
Betreuerausweis.
Dasselbe gilt für `SICHERUNG_PASSWORT` — ohne das nützt dir die
verschlüsselte Sicherungsdatei nichts.
```bash
# Zum Abschreiben oder Kopieren
grep -E '^(ENC_KEY_V1|SICHERUNG_PASSWORT)=' .env
```
**Warum so umständlich?** Weil der Schlüssel bewusst nicht in der
Datenbank liegt. Ein gestohlenes Backup ist damit wertlos — und ein
verlorener Schlüssel macht auch dein eigenes Backup wertlos. Beides ist
dieselbe Eigenschaft, von zwei Seiten betrachtet.
Denk an die letzten Pflichten:
- **AV-Vertrag** mit Hoster und Objektspeicher abgeschlossen?
- [`verarbeitungsverzeichnis.md`](verarbeitungsverzeichnis.md) ausgefüllt?
- [`datenschutzhinweise.md`](datenschutzhinweise.md) ausgefüllt und der
betreuten Person ausgehändigt oder vorgelesen?
---
## Aktualisieren
```bash
cd /opt/betreuungs-cockpit
# 1. IMMER zuerst sichern - eine Migration lässt sich nicht zurückrollen
docker compose exec sicherung /bin/sh /sicherung.sh
# 2. Was ändert sich? Lies den Changelog, bevor du ziehst
git fetch --tags
git log --oneline HEAD..origin/main
# 3. Auf die neue Fassung wechseln
git checkout v1.1.0
# 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) \
BUILD_ZEIT=$(date -Iseconds) \
docker compose up -d --build
```
Die Migrationen laufen beim Start mit. **Sagt der Changelog, dass sich ein
SQL-Skript geändert hat, führ die beiden Befehle aus Schritt 5 erneut aus.**
### Kontrolle
```bash
curl -s https://cockpit.deine-domain.de/api/gesundheit
# version muss die neue sein
```
Steht dort noch die alte Version, läuft der alte Behälter weiter — dann
hat der Bau nicht durchgezogen. Sieh in `docker compose logs app`.
---
## Wenn etwas nicht klappt
### Ein Dienst startet nicht
```bash
docker compose ps
docker compose logs app --tail 100
docker compose logs db --tail 50
```
Fehlt eine Umgebungsvariable, sagt die Anwendung beim Start, welche.
### „Umgebungsvariable ENC_KEY_V1 fehlt"
Die `.env` liegt nicht neben der `docker-compose.yml`, oder die Zeile ist
leer. Prüf mit dem Befehl aus Schritt 3.
### Port 3000 ist schon belegt
```bash
sudo ss -tlnp | grep 3000
```
Setz `APP_PORT=3010` in der `.env`, dann `docker compose up -d`, und zeig
mit dem Proxy auf den neuen Port.
### Die Anmeldung landet wieder auf dem Formular, ohne Fehlermeldung
`AUTH_URL` stimmt nicht mit der aufgerufenen Adresse überein. Häufig:
`http` statt `https`, oder ein Schrägstrich am Ende. Nach dem Ändern:
```bash
docker compose up -d
```
### „Zu viele Fehlversuche"
Nach fünf Fehlversuchen wird für eine Viertelstunde gesperrt — auch dich.
Warte ab, oder setz die Zähler zurück:
```bash
docker compose exec db psql -U cockpit betreuungs_cockpit \
-c "DELETE FROM login_versuch WHERE erfolg = false;"
```
### Der Code aus der App wird nicht angenommen
Die Uhr des Telefons geht falsch. TOTP-Codes hängen an der Uhrzeit. Stell
die automatische Zeitsynchronisierung ein und prüf, dass auch der Server
richtig geht:
```bash
timedatectl
docker compose exec app date
```
### Die Texterkennung findet nichts
```bash
docker compose exec app tesseract --list-langs
# deu muss dabei sein
```
Fehlt sie, ist das Abbild nicht vollständig gebaut:
`docker compose build --no-cache app`.
### Uploads schlagen bei großen Dateien fehl
Der Proxy schneidet ab. Bei Caddy `request_body max_size`, bei nginx
`client_max_body_size` — beide auf mindestens 30 MB.
### Die Seite lädt, aber ohne Gestaltung, und nichts lässt sich anklicken
Dein Proxy überschreibt die Content-Security-Policy der Anwendung. Sie
enthält eine Nonce, die pro Anfrage neu ist — eine feste CSP aus dem Proxy
blockiert dann die eigenen Skripte. Nimm die Kopfzeile aus dem Proxy
heraus.
### Der Speicherplatz wird knapp
```bash
df -h
docker system prune -a # entfernt ungenutzte Abbilder
```
**Ohne `--volumes`**, sonst löschst du die Datenbank.
### Ganz von vorn
```bash
docker compose down -v # ACHTUNG: löscht ALLE Daten
docker compose up -d --build
```
Das `-v` entfernt die Datenbank. Nur, wenn du das wirklich willst.
---
## Abnahmeliste
Hak das durch, bevor du echte Daten einträgst:
- [ ] AV-Vertrag mit dem Hoster abgeschlossen
- [ ] AV-Vertrag für den Objektspeicher abgeschlossen (falls extern)
- [ ] Server steht in Deutschland oder mindestens in der EU
- [ ] `.env` hat Rechte `600`
- [ ] Alle vier Dienste laufen, Datenbank und Anwendung `healthy`
- [ ] `/api/gesundheit` antwortet mit der erwarteten Version
- [ ] **Audit-Schutz greift** — `UPDATE audit_log` endet mit Fehler
- [ ] Bucket im Objektspeicher angelegt
- [ ] `https://` funktioniert, Zertifikat gültig
- [ ] Sicherheitskopfzeilen kommen durch den Proxy an
- [ ] **Der Anwendungsport ist von außen nicht erreichbar** (Gegenprobe aus Schritt 7)
- [ ] Der Weg Proxy → Anwendung läuft über ein privates Netz oder einen Tunnel
- [ ] Zweiter Faktor eingerichtet
- [ ] **Wiederherstellungscodes ausgedruckt** und weggelegt
- [ ] Bestellungsdatum eingetragen, Pflichtfristen abgeleitet
- [ ] Sicherung von Hand angestoßen, Selbstprüfung bestanden
- [ ] Sicherung **einmal zurückgespielt** und geprüft
- [ ] Sicherungen werden vom Server weg kopiert
- [ ] Wartungslauf im cron eingetragen und einmal getestet
- [ ] **`ENC_KEY_V1` im Passwortmanager und ausgedruckt**
- [ ] `SICHERUNG_PASSWORT` ebenso
- [ ] Verarbeitungsverzeichnis ausgefüllt
- [ ] Datenschutzhinweise ausgefüllt und ausgehändigt
Ist alles abgehakt, kannst du anfangen. Die
[Bedienungsanleitung](ANLEITUNG.md) sagt dir, wie.