# 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.1.1 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da git describe --tags # v1.1.1 ``` --- ## 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= # --- Anmeldung --- AUTH_SECRET= AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https AUTH_TRUST_HOST=true # --- Verschlüsselung --- ENC_KEY_V1= ENC_KEY_AKTIV=1 INDEX_KEY= # --- Objektspeicher --- S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= # --- Sicherung --- SICHERUNG_PASSWORT= # --- 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: ```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= SMTP_FROM="Betreuungs-Cockpit " ``` 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.1.1","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 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. Vier Varianten, je nachdem was bei dir läuft: [Nginx Proxy Manager](#nginx-proxy-manager), [Caddy](#caddy), [nginx von Hand](#nginx) oder [Traefik](#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: ```nginx # 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 ```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.1.1",...} ``` 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://: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.1.1 · `. --- ## 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_.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: " https://cockpit.deine-domain.de/api/wartung > /dev/null ``` ### Kontrolle ```bash curl -s -H "X-Wartung-Schluessel: " \ 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 ./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 > 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.2.0` | 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: ```bash 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 ```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? git fetch --tags git log --oneline HEAD..origin/main # 3. Umschalten git checkout v1.1.1 # 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 ```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: ```bash docker compose up -d --build --force-recreate app ``` ### Zurück auf den vorherigen Stand ```bash 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 ```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. ### 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: ```bash 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: ```bash # auf dem Anwendungsserver curl -s http://127.0.0.1:3000/api/gesundheit # auf dem Proxy-Server curl -s http://: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 ```bash 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 ```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 ``` ### 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. ```bash docker compose exec app printenv AUTH_URL # muss mit https:// beginnen ``` Steht dort `http://`, korrigiere die `.env` und starte neu: ```bash 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. ```bash 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 ```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.