Files
betreuungs-cockpit/docs/INSTALLATION.md
T
2026-09-01 16:39:59 +02:00

43 KiB

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.

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 bringt dich in zehn Minuten auf dem eigenen Rechner zum Laufen.


Inhalt


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:

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:

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:

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.

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

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.10.2

Kontrolle

ls docker-compose.yml Dockerfile .env.example   # alle drei da
git describe --tags                              # v1.10.2

Schritt 2 — Schlüssel erzeugen

Die Anwendung braucht sieben Geheimnisse. Erzeuge sie auf dem Server, nicht auf dem eigenen Rechner:

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('ERSTEINRICHTUNG_GEHEIMNIS=' + w(24));
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

cp .env.example .env
nano .env

Die .env allein genügt nicht. Die docker-compose.yml reicht die Werte einzeln an den Behälter durch — sie hat kein env_file:. Ein selbst ergänzter Wert, der dort nicht aufgeführt ist, erreicht die Anwendung nicht, auch wenn er in der .env steht. Alle hier beschriebenen Werte sind eingetragen; ein Test wacht darüber (tests/umgebung-durchreichen.test.ts).

Diese Werte musst du setzen:

# --- Datenbank ---
POSTGRES_PASSWORD=<aus Schritt 2>

# --- Anmeldung ---
AUTH_SECRET=<aus Schritt 2>
AUTH_URL=https://cockpit.deine-domain.de      # exakt deine Adresse, mit https

# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen. Absicht:
# sonst steht die Einrichtungsseite bis zum ersten Anlegen fuer jeden offen.
ERSTEINRICHTUNG_GEHEIMNIS=<aus Schritt 2>

# Ein Reverse Proxy steht davor (Schritt 7). Nur damit wird der
# weitergereichte Kopf geglaubt, aus dem die Anmeldesperre den Anschluss
# liest. Ohne die Zeile zaehlt sie nur nach E-Mail-Adresse.
VERTRAUE_PROXY=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>
# S3_ENDPOINT bleibt AUSKOMMENTIERT, solange du das mitgelieferte MinIO
# benutzt. Nur dann setzt die docker-compose.yml von selbst
# http://minio:9000 - steht dort ein Wert, gewinnt er.

# --- Sicherung ---
SICHERUNG_PASSWORT=<aus Schritt 2>

# --- Erreichbarkeit ---
# Steht dein Proxy auf einem ANDEREN Server, muss hier die Adresse hinein,
# ueber die er diesen Server erreicht. Bleibt es bei 127.0.0.1, kommt er
# NICHT heran - und der Fehler sieht aus, als sei der Proxy schuld.
# Welcher Wert der richtige ist, steht in Schritt 7a.
APP_BIND=127.0.0.1
APP_PORT=3000

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.

Seit 1.1.0 hängt noch mehr daran: Aus dieser Adresse wird die Kennung abgeleitet, an die Passkeys gebunden werden. Steht sie falsch, lässt sich zwar ein Passkey einrichten, aber nie wieder verwenden. Und ein späterer Wechsel der Domain macht alle vorhandenen Passkeys unbrauchbar — das ist kein Fehler, sondern der Phishingschutz.

Läuft auf dem Server schon etwas auf Port 3000? Dann weich aus:

APP_PORT=3010

Auf welcher Adresse die Anwendung horcht, legst du in Schritt 7 fest — dort steht auch, warum 127.0.0.1 nicht reicht, wenn der Proxy woanders läuft.

Optional, aber empfohlen — Fristerinnerungen per E-Mail:

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:

chmod 600 .env

Kontrolle

# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ERSTEINRICHTUNG_GEHEIMNIS|VERTRAUE_PROXY|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \
  | sed 's/=.*/ = gesetzt/'

Zehn Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht — und sagt dir beim Start auch, welche. Zwei Ausnahmen: Fehlt ERSTEINRICHTUNG_GEHEIMNIS, startet die Anwendung, aber der erste Zugang lässt sich nicht anlegen. Fehlt VERTRAUE_PROXY, läuft alles — die Anmeldesperre zählt dann nur nach E-Mail-Adresse und schreibt beim ersten Mal eine Zeile ins Log.


Schritt 4 — Starten

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

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
# 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.12.0","commit":"...",
#  "speicher":{"status":"bereit","treiber":"s3"}}

Kommt hier nichts, sieh in die Logs:

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:

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:

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:

docker compose exec minio sh -c '
  mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
  mc mb "lokal/$S3_BUCKET" 2>/dev/null || true
  mc ls lokal
'

Es muss eine Zeile mit dem Namen deines Ablagebereichs erscheinen — standardmäßig betreuungs-cockpit/.

Warum der Befehl so umständlich aussieht. Er holt die Zugangsdaten aus den Variablen, die im MinIO-Behälter ohnehin stehen. Das hat drei Gründe, und jeder davon hat schon einmal Zeit gekostet:

  1. Kein Passwort in der Befehlszeile — es stünde sonst in der Verlaufsdatei deiner Shell.
  2. Kein source .env — exportierte Werte haben Vorrang vor der .env-Datei und überstimmen danach jedes docker compose up. Wer später die .env ändert und neu startet, sieht keine Wirkung und sucht den Fehler überall, nur nicht in der eigenen Shell.
  3. Der Alias überlebt kein Neuerstellen. mc alias set schreibt nach /root/.mc im Behälter. Nach jedem docker compose up -d minio ist er weg, und mc ls lokal liefert dann kommentarlos nichts — man hält seine Dokumente für verschwunden. Deshalb setzt jeder Aufruf den Alias selbst.

Der Bucket-Name kommt aus $S3_BUCKET und ist nicht fest verdrahtet: Sonst zeigen Anwendung und Ablagebereich aneinander vorbei, sobald jemand den Namen ändert.

Serverseitige Verschlüsselung? Brauchst du nicht. Die Anwendung verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit ENC_KEY_V1, der den Anbieter nie erreicht. Das mitgelieferte MinIO läuft ohne KMS und würde jede Ablage ablehnen, sobald S3_SSE in der .env steht. Setz den Wert nur, wenn ein externer Anbieter ihn ausdrücklich verlangt.

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.


Der Abschnitt speicher sagt, ob die Dateiablage benutzbar ist: erreichbar, Ablagebereich vorhanden, Schreibrecht da. Steht dort gestoert, lässt sich kein Dokument ablegen — alles andere funktioniert weiter. Die Erklärung steht im Log:

docker compose logs app | grep '\[speicher\]'

Geprüft wird beim ersten Aufruf dieser Adresse und danach höchstens alle fünf Minuten. Der Healthcheck ruft sie ohnehin auf, die Zeile steht also kurz nach dem Start im Log.


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:

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:

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:

# 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:

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.

Vier Varianten, je nachdem was bei dir läuft: Nginx Proxy Manager, Caddy, nginx von Hand oder Traefik.

Nginx Proxy Manager

In der Oberfläche: Hosts → Proxy Hosts → Add Proxy Host.

Reiter „Details"

Feld Wert
Domain Names cockpit.deine-domain.de
Scheme http — nicht https. Die Anwendung beendet kein TLS
Forward Hostname / IP die Adresse aus 7a, z. B. 10.0.0.5
Forward Port 3000 (oder dein APP_PORT)
Cache Assets aus
Block Common Exploits an
Websockets Support an
Access List leer lassen — die Anwendung hat eine eigene Anmeldung mit Pflicht-Zweitfaktor

Zu Cache Assets: Next.js hängt an jede Skript- und Stildatei einen Prüfsummen-Namen, ein Zwischenspeicher bringt hier also nichts. Aus bleibt er, damit nach einem Update niemand rätselt, ob er noch eine alte Fassung sieht.

Reiter „SSL"

Feld Wert
SSL Certificate Request a new SSL Certificate (Let's Encrypt)
Force SSL an
HTTP/2 Support an
HSTS Enabled aus
HSTS Subdomains aus

HSTS Enabled bleibt aus, weil die Anwendung den Kopf Strict-Transport-Security bereits selbst setzt — sonst schickt der Server ihn zweimal.

Reiter „Advanced"

Hier kommt der Teil hinein, den die Oberfläche nicht abbildet. Genau dieser Block:

# Fotos mehrseitiger Bescheide sind schnell 10 MB.
client_max_body_size 30M;

# Belegmappe, Rechnungslegung und Vollexport brauchen länger als die
# üblichen 60 Sekunden. Ohne diese Werte bricht der Download mittendrin ab.
proxy_connect_timeout  30s;
proxy_send_timeout    600s;
proxy_read_timeout    600s;

# Der Vollexport kommt als Strom. Puffert der Proxy, wartet der Browser,
# bis alles im Speicher des Proxys liegt - und läuft dabei in die Zeitgrenze.
proxy_buffering         off;
proxy_request_buffering off;

Dann Save.

Was du NICHT eintragen musst: proxy_set_header für Host, X-Real-IP, X-Forwarded-For und X-Forwarded-Proto. Nginx Proxy Manager setzt diese vier bereits selbst, und zwar im location-Block — ein zweiter Satz im Advanced-Feld läge auf server-Ebene und würde ohnehin überschrieben. Trag sie nicht ein, sonst suchst du später an der falschen Stelle.

Wenn du eine Access List benutzt: Sie sperrt auch /api/wartung aus. Entweder du lässt sie weg, oder du trägst die IP des Servers ein, von dem der cron-Eintrag aus Schritt 10 kommt.

Caddy

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

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:

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:

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:

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?

curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.10.2",...}

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:

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:

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, 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.10.2 · <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:

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
Datenbank gesichert (1.2M)
Dokumente gesichert (18M)
Selbstpruefung bestanden - Datenbank und Dokumente.

Die Selbstprüfung ist der eigentliche Punkt: Das Skript entschlüsselt beide Dateien sofort wieder und liest sie einmal durch. Schlägt das fehl, endet der Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.

Fehlt die Zeile Dokumente gesichert, ist das Ablageverzeichnis nicht in den Sicherungsdienst eingehängt. Das Skript bricht dann ab und räumt den angefangenen Datenbankstand wieder weg — eine Sicherung, die nur die halbe Wahrheit enthält, aber vollständig aussieht, wäre schlimmer als gar keine.

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:

# 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

Beim Zurückspielen packt das Skript auch das Dokumentenarchiv aus — nach backups/dokumente-wiederhergestellt_<datum>/. An ihren Platz schreibt es die Dateien nicht: Der Sicherungsdienst hat auf das Ablageverzeichnis bewusst nur Lesezugriff, und in ein laufendes MinIO hineinzuschreiben würde dessen Datenbestand beschädigen. Wie der letzte Handgriff aussieht, gibt das Skript am Ende selbst aus.

Für die Übung genügt ein Blick in das ausgepackte Verzeichnis:

find backups/dokumente-wiederhergestellt_*/ -type f | head
rm -rf backups/dokumente-wiederhergestellt_*

Im Ernstfall gehört beides zusammen. Eine Datenbank ohne die zugehörigen Dateien ergibt eine Akte, deren Prüfsummen Dokumente belegen, die es nicht mehr gibt. Spiel immer das Paar mit demselben Zeitstempel zurück.

Kontrolle

ls -lh backups/
# cockpit_....dump.enc  und  cockpit_....dump.enc.sha256

Was das Backup enthält: die Datenbank und die Dokumente. Je Lauf entstehen zwei verschlüsselte Dateien mit demselben Zeitstempel:

cockpit_2026-09-01_0315.dump.enc        <- Akte, Fristen, Buchungen
dokumente_2026-09-01_0315.tar.gz.enc    <- Bescheide, Schreiben, Belege

Was es nicht enthält: den Schlüssel ENC_KEY_V1. Ohne ihn bleiben auch aus einer vollständig zurückgespielten Sicherung die Diagnosen, IBANs, Notizen und Dokumente unlesbar. Er gehört an die zwei Orte aus Schritt 11.

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.

# 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:

crontab -e

Diese Zeile ans Ende:

30 4 * * * curl -fsS -H "X-Wartung-Schluessel: <ausgegebener Wert>" https://cockpit.deine-domain.de/api/wartung > /dev/null

Kontrolle

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.

# 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:


Wo deine Daten liegen

Drei Orte, und keiner davon ist ein Ordner, den du einfach öffnest.

Was Wo In der täglichen Sicherung?
Dokumente (Bescheide, Schreiben, Belege) Docker-Volume minio-daten ja
Datenbank (Akte, Fristen, Buchungen) Docker-Volume postgres-daten ja
Sicherungen ./backups neben der docker-compose.yml —

Den Pfad auf der Platte finden

docker volume inspect betreuungs-cockpit_minio-daten --format '{{.Mountpoint}}'
# /var/lib/docker/volumes/betreuungs-cockpit_minio-daten/_data

Heißt dein Projektverzeichnis anders, heißen auch die Volumes anders — Docker stellt den Verzeichnisnamen voran:

docker volume ls | grep -E "minio-daten|postgres-daten"

Dort hineinzusehen bringt nichts. MinIO legt jedes Dokument als eigenes Verzeichnis mit Metadatei und Datenteilen ab, und der Inhalt ist verschlüsselt. Du fändest dort keine anklickbaren PDFs, sondern Fragmente. Wer die Dokumente sehen will, nimmt die Anwendung.

Nachsehen, was abgelegt ist

docker compose exec minio sh -c '
  mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
  mc ls --recursive "lokal/$S3_BUCKET"
'

Je Dokument eine Zeile unter betreute/<id>/dokumente/<uuid>.pdf.

Prüfen, ob Akte und Speicher übereinstimmen

Die wichtigste Kontrolle: Zu jedem Eintrag in der Akte muss eine Datei gehören. Fehlt sie, belegt eine Prüfsumme ein Dokument, das es nicht mehr gibt — gegenüber dem Betreuungsgericht der schlechteste Zustand.

docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c "SELECT \"objektSchluessel\" FROM dokument WHERE \"geloeschtAm\" IS NULL ORDER BY 1;"' | sort > /tmp/erwartet.txt

docker compose exec minio sh -c '
  mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
  mc ls --recursive "lokal/$S3_BUCKET"
' | awk '{print $NF}' | sort > /tmp/vorhanden.txt

echo "In der Akte: $(wc -l < /tmp/erwartet.txt)  Im Speicher: $(wc -l < /tmp/vorhanden.txt)"
echo "--- Fehlt im Speicher (Handlungsbedarf): ---"
comm -23 /tmp/erwartet.txt /tmp/vorhanden.txt
echo "--- Verwaist im Speicher (harmlos): ---"
comm -13 /tmp/erwartet.txt /tmp/vorhanden.txt

Beide Zahlen gleich und beide Listen leer — dann passt es.

Ein Eintrag unter „Fehlt im Speicher" ist ein Akteneintrag ohne Datei. Er entsteht, wenn eine Ablage scheitert. Solche Einträge löschen und das Dokument neu anlegen.

Ohne welche Schlüssel nichts davon lesbar ist

Schlüssel Ohne ihn
ENC_KEY_V1 Diagnosen, IBANs, Notizen und alle Dokumente bleiben unlesbar — auch aus jeder Sicherung
SICHERUNG_PASSWORT keine Sicherung lässt sich öffnen
MINIO_KMS_SECRET_KEY nur falls gesetzt: MinIO gibt die Objekte nicht mehr heraus

Alle gehören an die zwei Orte aus Schritt 11, und keiner davon ist dieser Server.

Eigene Anpassungen, die ein Update überleben

Änderungen an der docker-compose.yml legt scripts/update.sh bei jedem Update per git stash beiseite — sie sind versioniert. Wer etwas dauerhaft ändern will, legt stattdessen eine docker-compose.override.yml daneben:

services:
  app:
    environment:
      EIGENER_WERT: ${EIGENER_WERT:-}

Docker Compose liest sie automatisch mit. update.sh prüft nur versionierte Dateien (git status --untracked-files=no) und lässt sie deshalb unangetastet.


Aktualisieren

cd /opt/betreuungs-cockpit
./scripts/update.sh

Kommst du von 1.1.0 oder älter? Dort lag update.sh ohne Ausführungsrecht im Repository — ./scripts/update.sh endet dann in „Permission denied". Einmalig hilft:

bash scripts/update.sh

Ab 1.1.1 ist das Recht gesetzt und der Punktschrägstrich funktioniert.

Das war es. Das Skript nimmt dir die Schritte ab, die man von Hand vergisst:

  1. Prüft Verzeichnis, git, docker und .env
  2. Holt die Fassungen und zeigt, was zwischen deinem Stand und dem neuen im Changelog steht
  3. Fragt nach
  4. Stößt eine Sicherung an — und bricht ab, wenn sie nicht klappt
  5. Legt eigene Änderungen an versionierten Dateien beiseite, schaltet um, baut neu
  6. Wartet, bis die Anwendung antwortet
  7. Führt die beiden SQL-Skripte aus (Volltextsuche, Audit-Schutz)
  8. Prüft, ob wirklich die neue Fassung läuft

Bis Schritt 3 wird nichts verändert. Bricht etwas vorher ab, findest du alles so vor wie zuvor.

Aufrufmöglichkeiten

Aufruf Wirkung
./scripts/update.sh auf die neueste veröffentlichte Fassung
./scripts/update.sh v1.10.2 auf eine bestimmte Fassung
./scripts/update.sh --pruefen nur nachsehen, nichts ändern
./scripts/update.sh --ja ohne Rückfrage (für cron)
./scripts/update.sh --verwerfen eigene Änderungen wegwerfen statt beiseitelegen
./scripts/update.sh --ohne-sicherung Sicherung überspringen — sehr gut überlegen

Eigene Änderungen an der docker-compose.yml

Das ist der Normalfall: Man passt etwas an, bis es läuft. Das Skript legt solche Änderungen vor dem Umschalten mit git stash beiseite und sagt, wie man sie zurückholt:

git stash list      # was liegt da
git stash pop       # zurückholen
git stash drop      # wegwerfen

Sind deine Anpassungen inzwischen im Projekt eingearbeitet — wie APP_BIND oder MINIO_KONSOLE_PORT —, brauchst du sie nicht zurückzuholen. Dann setz die Werte einfach in der .env.

Die .env wird nie angefasst. Sie ist nicht versioniert; weder git checkout noch das Skript rühren sie an.

Von Hand, wenn du lieber selbst steuerst

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?
git fetch --tags
git log --oneline HEAD..origin/main

# 3. Umschalten
git checkout v1.10.2

# 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build

# 5. Die beiden SQL-Skripte - sie laufen NICHT mit den Migrationen mit
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

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:

docker compose up -d --build --force-recreate app

Zurück auf den vorherigen Stand

git checkout v1.0.4
docker compose up -d --build

Hat die neue Fassung die Datenbank migriert, reicht das nicht — dann muss auch die Sicherung zurück (siehe Schritt 9).


Wenn etwas nicht klappt

Ein Dienst startet nicht

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.

Der Proxy erreicht die Anwendung nicht („502", „Connection refused")

Fast immer steht in der .env noch APP_BIND=127.0.0.1, während der Proxy auf einem anderen Server läuft. Dann horcht die Anwendung nur auf der Loopback-Adresse — erreichbar ausschließlich vom Anwendungsserver selbst.

Prüf, worauf sie tatsächlich horcht:

docker compose ps
# unter PORTS muss die Adresse stehen, die der Proxy erreicht,
# NICHT 127.0.0.1

Und ob sie überhaupt antwortet — erst lokal, dann vom Proxy-Server aus:

# auf dem Anwendungsserver
curl -s http://127.0.0.1:3000/api/gesundheit

# auf dem Proxy-Server
curl -s http://<APP_BIND-Adresse>:3000/api/gesundheit

Antwortet der erste, aber nicht der zweite, ist APP_BIND falsch oder eine Firewall dazwischen. Setz den richtigen Wert (Schritt 7a) und

docker compose up -d

Ein Neubau des Abbilds ist dafür nicht nötig — es ist nur eine Umgebungsänderung, der Behälter wird neu erzeugt.

Port 3000 ist schon belegt

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:

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:

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:

timedatectl
docker compose exec app date

Der Knopf „Mit Passkey anmelden" erscheint nicht

Passkeys brauchen einen sicheren Kontext. Die Anwendung bietet sie nur an, wenn AUTH_URL auf https:// steht (oder auf localhost) — sonst erscheint der Knopf bewusst gar nicht erst, statt beim Klick mit einer kryptischen Browsermeldung abzubrechen.

docker compose exec app printenv AUTH_URL    # muss mit https:// beginnen

Steht dort http://, korrigiere die .env und starte neu:

docker compose up -d

Erscheint der Knopf weiterhin nicht, kann der Browser kein WebAuthn — das betrifft praktisch nur sehr alte Fassungen.

Ein Passkey lässt sich einrichten, aber nicht zum Anmelden benutzen

Fast immer stimmt die Adresse nicht überein. Der Schlüssel ist an den Hostnamen aus AUTH_URL gebunden; der Browser vergleicht ihn mit dem, was in der Adresszeile steht.

docker compose exec app printenv AUTH_URL
# muss exakt die Adresse sein, die du im Browser eintippst

Typische Ursachen: AUTH_URL zeigt auf cockpit.example.de, aufgerufen wird aber www.cockpit.example.de — oder umgekehrt. Ein Passkey für die eine Adresse gilt auf der anderen nicht.

Nach einem Wechsel der Domain müssen alle Passkeys neu eingerichtet werden. Die alten Einträge lassen sich unter Mein Konto → Passkeys entfernen. Die Anmeldung mit E-Mail, Passwort und Code funktioniert in der Zwischenzeit unverändert — man sperrt sich damit also nicht aus.

Die Texterkennung findet nichts

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

df -h
docker system prune -a           # entfernt ungenutzte Abbilder

Ohne --volumes, sonst löschst du die Datenbank.

Ganz von vorn

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 sagt dir, wie.