Bisher gab es fuer Kollegen keine Dokumentation: im Projekt lagen nur das CI/CD-Runbook und die Arbeitsanweisungen fuer die Entwicklung. Diese Luecke schliessen vier Anleitungen plus eine Einstiegsseite unter docs/. Alle vier wurden gegen den Quelltext geschrieben, nicht aus der Planung abgeleitet, und anschliessend unabhaengig gegengeprueft: jede zitierte Beschriftung ist woertlich aus de.json belegt, jede beschriebene Funktion im Code nachgewiesen, alle Befehle und Pfade gegen die echten Compose-Dateien, Dockerfiles und package.json-Skripte verifiziert. Die Gegenpruefung fand keine falsche Aussage. Die Einstiegsseite hebt die drei Punkte hervor, die in der Praxis am meisten Zeit gekostet haben: Anmeldung ueber den Benutzernamen statt der E-Mail, der Unterschied zwischen aktiviert und freigegeben, und dass ein blosses 'up -d' die laufenden Container nicht ersetzt. Nebenbefund beim Schreiben des Betriebshandbuchs, als #17 im Ledger erfasst: user-files/ ist in keiner Compose-Datei als Volume eingebunden — hochgeladene Profilbilder und DKV-Exporte ueberleben kein --force-recreate. Noch ohne Schaden, da bisher kein Nutzer ein Profilbild hinterlegt hat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
18 KiB
Tessera – Betriebshandbuch
Anleitung für den laufenden Betrieb von Tessera: Erstinstallation, neue Fassungen einspielen, Datenbank-Migrationen, Sicherung/Wiederherstellung und Fehlersuche. Richtet sich an Kolleg:innen, die die Docker-Container auf dem Server betreiben.
Für den Aufbau der CI/CD-Pipeline (Gitea Actions, act_runner) siehe
docs/ci-cd-setup.md – dieses Dokument behandelt nur den
Betrieb der bereits laufenden Installation, nicht deren automatisierten Build.
Inhaltsverzeichnis
- Überblick der Dienste
- Erstinstallation
- Konfiguration
- Neue Fassung einspielen
- Datenbank-Migrationen
- Sicherung und Wiederherstellung
- Protokolle und Fehlersuche
- Abgrenzung zur CI/CD-Pipeline
1. Überblick der Dienste
Tessera besteht aus drei Containern, definiert in docker-compose.yml (Basis) und
docker-compose.prod.yml (Produktionsvariante mit fertigen Images statt lokalem Build):
| Dienst | Image / Build | Host-Port | Zweck |
|---|---|---|---|
web |
apps/web/Dockerfile (Basis) bzw. git.vicolab.de/schalli/tessera-ctl/web:latest (Prod) |
3000 | Next.js-Frontend (Portal, Dashboard) |
api |
apps/api/Dockerfile (Basis) bzw. git.vicolab.de/schalli/tessera-ctl/api:latest (Prod) |
3001 | NestJS-Backend (REST-API, Prisma/PostgreSQL-Zugriff) |
db |
postgres:16-alpine |
kein Host-Port | PostgreSQL-Datenbank |
Zusätzlich existiert docker-compose.dev.yml (Bind-Mounts für Live-Reload,
Mailhog, OpenLDAP, phpLDAPadmin) für die lokale Entwicklung sowie
docker-compose.ci.yml für den Gitea act_runner – beide sind für den
Produktivbetrieb ohne Belang und werden hier nicht weiter behandelt.
Netzwerke (siehe docker-compose.yml):
frontend-net– nurweb, darüber ist der Browser-Zugriff erreichbar (Port 3000).backend-net–webundapi, Kommunikation zwischen Frontend-Server und Backend.data-net–apiunddb, alsinternal: truemarkiert. Die Datenbank ist damit aus dem Host-Netzwerk grundsätzlich nicht erreichbar; Zugriff nur überdocker compose exec db ...von innen.
Startreihenfolge: db → api → web, erzwungen über depends_on mit
condition: service_healthy:
db: Healthcheckpg_isready -U tessera(Intervall 5 s, 5 Versuche).api: Healthcheckwget --spider http://localhost:3001/health(Intervall 10 s, 3 Versuche,start_period10 s in der Basis- / 20 s in der Prod-Variante). Der/health-Endpunkt (apps/api/src/health/health.controller.ts) prüft nur, dass der NestJS-Prozess antwortet – keine Datenbankverbindung. Ein "healthy" API-Container sagt also nichts darüber aus, ob Prisma tatsächlich verbunden ist.web: hat selbst keinen Healthcheck.docker compose pszeigtwebdaher nie als "healthy" an, nur als laufend – das ist normal.
Der Browser spricht ausschließlich mit web (Port 3000). Aufrufe unter
/api-proxy/* werden von Next.js serverseitig auf API_INTERNAL_URL
(http://api:3001 im Docker-Netz) umgeschrieben (siehe apps/web/next.config.ts).
Der Browser erreicht die API also nie direkt und muss auch keine eigene CORS-Freigabe
für die API-Origin haben – Port 3001 muss von außen in der Regel nicht erreichbar
sein, ist es in den mitgelieferten Compose-Dateien aber (Host-Port 3001 ist
gemappt).
2. Erstinstallation
Voraussetzung: Docker und Docker Compose (Plugin, docker compose ...) sind auf dem
Zielsystem installiert.
-
Repository auf den Server holen:
git clone <gitea-url>/schalli/tessera-ctl.git cd tessera-ctl -
.envaus der Vorlage anlegen:cp .env.prod.example .envDie Vorlage
.env.prod.exampleenthält die für den Produktivbetrieb relevanten Variablennamen;.env.exampleist die schlankere Entwicklungsvariante. Welche Variablen zu setzen sind, steht in Kapitel 3. Konfiguration. Mindestens erforderlich, bevor irgendetwas startet:DB_PASSWORDund dazu passendDATABASE_URLJWT_SECRETTESSERA_ADMIN_EMAIL,TESSERA_ADMIN_PASSWORDTESSERA_ENCRYPTION_KEY(siehe Warnhinweis unten – ohne diesen Wert startet nichts)
-
Encryption-Key erzeugen und eintragen:
openssl rand -hex 32Das Ergebnis (64 Hex-Zeichen) als
TESSERA_ENCRYPTION_KEY=in die.enveintragen. Dieser Schlüssel verschlüsselt alle bei Tessera hinterlegten Zugangsdaten (LDAP-Bind-Passwort, SMTP, Kalender- sowie DKV-/Ausschreibungs-Postfächer). Ohne diesen Wert startet die API nicht –docker-compose.prod.ymlverwendet die Compose-Syntax${TESSERA_ENCRYPTION_KEY:?...}, wodurch bereitsdocker composeselbst mit einer Fehlermeldung abbricht, bevor ein Container startet, falls die Variable fehlt. Zusätzlich prüftCryptoService(apps/api/src/crypto/crypto.service.ts) beim Hochfahren der API die Länge (muss genau 64 Hex-Zeichen sein) und wirft andernfalls einen Fehler.Diesen Schlüssel getrennt von jedem Datenbank-Dump aufbewahren (siehe Kapitel 6) – geht er verloren, sind alle gespeicherten Zugangsdaten unwiderruflich unbrauchbar und müssen von Hand neu eingegeben werden.
-
Container starten:
docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -dBeim ersten Start seedet die API (
AdminSeedService,apps/api/src/user/admin-seed.service.ts) automatisch den initialen Super-Admin-Account ausTESSERA_ADMIN_USER/_EMAIL/_PASSWORDsowie den Mandantendefault. Das passiert nur, wenn noch kein Benutzer mit diesem Nutzernamen existiert – bei einer bereits befüllten Datenbank wird der Seed-Schritt stillschweigend übersprungen, auch wenn sich die Passwort-Variable in der.envdanach ändert. -
Prüfen, ob alles läuft:
docker compose -f docker-compose.prod.yml ps curl -s http://localhost:3001/healthapisollte alshealthymarkiert sein, die/health-Abfrage liefert{"status":"ok",...}. Danach ist die Oberfläche unter Port 3000 erreichbar.
3. Konfiguration
Alle Variablen werden über .env im Projektverzeichnis eingelesen (Docker Compose
liest diese Datei automatisch). Werte unten sind Platzhalter, keine echten
Zugangsdaten.
| Variable | Pflicht in Prod? | Default (Prod-Compose) | Zweck |
|---|---|---|---|
DB_PASSWORD |
ja | – | Passwort des PostgreSQL-Benutzers tessera, an den db-Container durchgereicht. |
DATABASE_URL |
ja | – | Vollständiger Prisma-Connection-String, z. B. postgresql://tessera:<passwort>@db:5432/tessera. Muss zum DB_PASSWORD passen. |
JWT_SECRET |
ja | – | Signiert/prüft die JWT-Auth-Cookies. Wird sowohl an api als auch an web durchgereicht – beide müssen denselben Wert erhalten, sonst schlägt die Anmeldung fehl. In der Basis-Compose (Dev) gibt es einen unsicheren Default (tessera-dev-jwt-secret-change-in-production), in Prod nicht. |
TESSERA_ENCRYPTION_KEY |
ja | – | AES-256-GCM-Schlüssel (64 Hex-Zeichen) für gespeicherte Drittanbieter-Zugangsdaten. Siehe Kapitel 2. CALENDAR_ENCRYPTION_KEY ist der alte Variablenname und wird weiterhin akzeptiert (mit Warnung im Log). |
TESSERA_ADMIN_USER |
nein | admin |
Benutzername des initialen Super-Admin, nur beim allerersten Start relevant. |
TESSERA_ADMIN_EMAIL |
ja (für den Seed) | – | E-Mail des initialen Super-Admin. |
TESSERA_ADMIN_PASSWORD |
ja (für den Seed) | – | Initiales Passwort des Super-Admin. |
TESSERA_FORCE_CHANGE |
nein | true |
Erzwingt Passwortwechsel beim ersten Login des geseedeten Admin-Accounts. |
TESSERA_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD / _FROM |
nein (aber ohne Host kein Mailversand) | Host leer, Port 587, _SECURE=false |
SMTP-Relay für ausgehende Mails (Passwort-Reset, Benachrichtigungen). |
APP_URL |
empfohlen | http://localhost:3001 (für NEXT_PUBLIC_API_URL) / http://localhost:3000 (für TESSERA_APP_URL) |
Öffentliche Basis-URL der Web-Oberfläche. Wird serverseitig u. a. für in E-Mails generierte Links verwendet (TESSERA_APP_URL). |
API_INTERNAL_URL |
fest verdrahtet | http://api:3001 |
Adresse, unter der web die API innerhalb des Docker-Netzes erreicht; dorthin schreibt Next.js die /api-proxy/*-Rewrites um. In der Regel nicht ändern. |
Hinweis zu NEXT_PUBLIC_API_URL: Diese Variable wird beim Image-Build bereits fest
auf /api-proxy gesetzt (ENV NEXT_PUBLIC_API_URL=/api-proxy in
apps/web/Dockerfile, vor pnpm build). Next.js baut NEXT_PUBLIC_*-Variablen zur
Build-Zeit in das an den Browser ausgelieferte JavaScript ein; der zur Laufzeit über
Compose gesetzte Wert (${APP_URL:-http://localhost:3001}) hat auf das bereits
gebaute Frontend-Bundle daher keine sichtbare Wirkung mehr. Praktisch reicht es, dass
APP_URL für TESSERA_APP_URL (serverseitig, z. B. Mail-Links) korrekt gesetzt ist.
Wichtig – Konfigurationsdrift auf dem Server: Auf dem Produktivserver wurde die
laufende Compose-Datei in der Vergangenheit direkt von Hand angepasst und ist damit
nicht mehr zwingend identisch mit docker-compose.prod.yml in diesem Repository.
Änderungen an docker-compose*.yml im Git-Repo wirken sich nicht automatisch
auf die laufende Installation aus – der Deploy-Schritt zieht ausschließlich neue
Images, die Compose-Datei selbst muss bei Bedarf separat auf dem Server aktualisiert
werden. Vor grösseren Änderungen an der Compose-Struktur die auf dem Server
tatsächlich liegende Datei prüfen, nicht blind von der Repo-Version ausgehen.
4. Neue Fassung einspielen
Das ist der wichtigste Ablauf im Tagesgeschäft. Zwei Befehle:
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --force-recreate api web
Verifizierte Falle: docker compose up -d ohne --force-recreate ersetzt
einen bereits laufenden Container nicht, wenn Compose der Meinung ist, an der
Service-Definition habe sich nichts geändert – auch wenn pull gerade ein neues
Image unter demselben Tag (:latest) heruntergeladen hat. Der alte Container läuft
dann unverändert mit dem alten Code weiter, ohne Fehlermeldung. Das Deployment
wirkt erfolgreich, ist es aber nicht. Dieser Fehler ist dem Team schon mehrfach
passiert. Deshalb: nach jedem pull immer --force-recreate verwenden (nur
api und web betrifft das – db soll normalerweise nicht neu erstellt werden,
sonst würde sie kurz neu starten).
Kontrolle, ob es wirklich funktioniert hat: Startzeitpunkt des Containers mit dem Erstellungszeitpunkt des zugehörigen Images vergleichen. Der Container muss nach dem Image erzeugt worden sein:
docker inspect -f '{{.State.StartedAt}}' tessera-api-1
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/api:latest
docker inspect -f '{{.State.StartedAt}}' tessera-web-1
docker image inspect -f '{{.Created}}' git.vicolab.de/schalli/tessera-ctl/web:latest
(Container-Namen mit docker compose -f docker-compose.prod.yml ps prüfen, falls
sie auf dem Server abweichen.) Liegt StartedAt vor Created des Images, läuft
noch die alte Version – dann --force-recreate nachholen.
5. Datenbank-Migrationen
Ein separater Migrationsschritt ist nicht nötig. Der api-Container führt
Prisma-Migrationen bei jedem Start automatisch aus, bevor der eigentliche Prozess
hochfährt (apps/api/Dockerfile, CMD):
prisma migrate deploy --schema apps/api/prisma/schema.prisma && node apps/api/dist/main.js
Das heisst: Sobald ein neues Image (mit neuen Migrationen im Ordner
apps/api/prisma/migrations/) per pull + --force-recreate eingespielt wird,
laufen ausstehende Migrationen beim nächsten Start automatisch. Schlägt eine
Migration fehl, startet node apps/api/dist/main.js erst gar nicht – der Container
bleibt dann im Neustart-Loop bzw. terminiert, sichtbar in docker compose logs api.
Kontrolle, ob eine Migration tatsächlich angekommen ist:
docker compose -f docker-compose.prod.yml exec db \
psql -U tessera -d tessera -c \
"SELECT migration_name, finished_at FROM _prisma_migrations ORDER BY finished_at DESC LIMIT 5;"
Der Name der zuletzt erwarteten Migration lässt sich mit dem Ordnernamen in
apps/api/prisma/migrations/ abgleichen (Namensschema
YYYYMMDDHHMMSS_beschreibung).
6. Sicherung und Wiederherstellung
Es gibt aktuell kein eingebautes Backup-Skript und kein Makefile-Target dafür im Repository. Sicherung erfolgt manuell über die Postgres-Bordmittel.
Datenbank sichern
docker compose -f docker-compose.prod.yml exec db \
pg_dump -U tessera -d tessera -F c -f /tmp/tessera-$(date +%Y%m%d).dump
docker compose -f docker-compose.prod.yml cp db:/tmp/tessera-$(date +%Y%m%d).dump ./
Datenbank wiederherstellen
docker compose -f docker-compose.prod.yml cp ./tessera-YYYYMMDD.dump db:/tmp/restore.dump
docker compose -f docker-compose.prod.yml exec db \
pg_restore -U tessera -d tessera --clean --if-exists /tmp/restore.dump
Vor einer Wiederherstellung api stoppen, damit keine Schreibzugriffe während des
Restores stattfinden.
Was sonst noch an Zustand existiert
- PostgreSQL-Daten: liegen im benannten Docker-Volume
pgdata(docker-compose.prod.yml, Mountpgdata:/var/lib/postgresql/dataimdb-Container). Dieses Volume ist der einzige daürhafte Datenspeicher, der über eindocker volume-Backup zusätzlich gesichert werden könnte. - Verschlüsselungsschlüssel
TESSERA_ENCRYPTION_KEY: liegt nur in.envauf dem Host, nicht im Datenbank-Dump. Getrennt sichern (siehe Kapitel 2/3) – ohne ihn sind alle perpg_dumpgesicherten verschlüsselten Zugangsdaten wertlos. - Hochgeladene Dateien (Avatare unter
user-files/avatars/, generierte DKV-Exporte unteruser-files/, sieheapps/api/src/user/user.controller.tsundapps/api/src/dkv/dkv-export.service.ts): Diese Dateien landen im Verzeichnisuser-files/innerhalb desapi-Containers. Wederdocker-compose.ymlnochdocker-compose.prod.ymlmounten dafür ein Docker-Volume oder ein Host-Verzeichnis – der Ordner existiert ausschließlich in der beschreibbaren Container-Schicht. Das bedeutet: Bei jedem--force-recreatevonapigehen Avatare und DKV-Exporte verloren, sofern auf dem Server nicht zusätzlich (außerhalb des Repo-Standes) ein Bind-Mount ergänzt wurde. Vor einem Redeploy prüfen, ob auf dem Server ein solcher Mount existiert (docker inspect tessera-api-1→Mounts); falls nicht, gelten Avatare/Exporte als nicht persistent und sollten bei Bedarf vorher manuell aus dem Container kopiert werden (docker compose cp api:/app/user-files ./user-files-backup).
7. Protokolle und Fehlersuche
Logs ansehen:
docker compose -f docker-compose.prod.yml logs -f api
docker compose -f docker-compose.prod.yml logs -f web
docker compose -f docker-compose.prod.yml logs -f db
Gesunder Start sieht so aus: db wird healthy, danach startet api und
protokolliert die durchgelaufenen Prisma-Migrationen sowie zuletzt
Tessera API running on port 3001 (aus apps/api/src/main.ts). Erst danach startet
web, weil depends_on: api: condition: service_healthy das erzwingt.
| Symptom | Wahrscheinliche Ursache | Prüfen / Beheben |
|---|---|---|
docker compose up bricht sofort ab, ohne dass ein Container startet, mit einer Meldung zu TESSERA_ENCRYPTION_KEY |
Variable fehlt in .env – Compose selbst verweigert die Variablen-Interpolation (${TESSERA_ENCRYPTION_KEY:?...}) |
TESSERA_ENCRYPTION_KEY setzen (siehe Kapitel 2), danach erneut starten. |
api-Container startet und beendet sich sofort wieder, Log zeigt TESSERA_ENCRYPTION_KEY is not set oder must be a 64-character hex string |
Key ist zwar irgendwo gesetzt, aber leer oder falsch lang | Mit openssl rand -hex 32 neu erzeugen, Länge prüfen (64 Zeichen). |
api wird nie healthy, Log zeigt Verbindungsfehler von Prisma/PostgreSQL |
db ist noch nicht bereit, DATABASE_URL falsch, oder DB_PASSWORD/DATABASE_URL passen nicht zusammen |
docker compose ps – ist db healthy? DATABASE_URL gegen DB_PASSWORD abgleichen. |
Login funktioniert nicht, obwohl api und web laufen |
JWT_SECRET bei web und api unterschiedlich, z. B. nach halbherzigem Neustart nur eines Containers |
Beide Container mit demselben JWT_SECRET neu erstellen (--force-recreate api web). |
Neue Version scheint nicht anzukommen, obwohl pull gelaufen ist |
Klassische up -d-Falle ohne --force-recreate (siehe Kapitel 4) |
StartedAt des Containers gegen Created des Images vergleichen, ggf. --force-recreate nachholen. |
Initialer Admin-Login funktioniert nicht nach Änderung von TESSERA_ADMIN_PASSWORD |
Seed läuft nur, wenn der Benutzername noch nicht existiert; bestehende Accounts werden nicht überschrieben | Passwort über die Anwendung selbst (bzw. direkt in der Datenbank) ändern, nicht über die .env-Variable. |
| Mails werden nicht versendet | TESSERA_SMTP_HOST leer (Prod-Default) |
SMTP-Variablen vollständig setzen und Container neu erstellen. |
| Avatare/DKV-Exporte nach einem Deploy verschwunden | user-files/ ist nicht als Volume gemountet, siehe Kapitel 6 |
Vor --force-recreate sichern, langfristig einen Bind-Mount für user-files/ ergänzen. |
8. Abgrenzung zur CI/CD-Pipeline
Dieses Dokument beschreibt den manuellen Betrieb: Erstinstallation, Redeploy, Migrations-Kontrolle, Backup und Fehlersuche auf einer bereits eingerichteten Installation.
Wie die Gitea-Actions-Pipeline (Runner-Registrierung, .gitea/workflows/ci.yml,
Berechtigungen des act_runner-Docker-Socket-Mounts) aufgesetzt wird, ist bewusst
nicht Teil dieses Dokuments – das steht vollständig in
docs/ci-cd-setup.md. Die Grenze zwischen beiden Dokumenten:
Sobald ein neues Image lokal vorliegt oder in einer Registry verfügbar ist, beginnt
dieses Betriebshandbuch (Kapitel 4); alles davor – wie das Image entsteht – gehört
in das CI/CD-Runbook.