# 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`](./ci-cd-setup.md) – dieses Dokument behandelt nur den Betrieb der bereits laufenden Installation, nicht deren automatisierten Build. ## Inhaltsverzeichnis 1. [Überblick der Dienste](#1-überblick-der-dienste) 2. [Erstinstallation](#2-erstinstallation) 3. [Konfiguration](#3-konfiguration) 4. [Neue Fassung einspielen](#4-neue-fassung-einspielen) 5. [Datenbank-Migrationen](#5-datenbank-migrationen) 6. [Sicherung und Wiederherstellung](#6-sicherung-und-wiederherstellung) 7. [Protokolle und Fehlersuche](#7-protokolle-und-fehlersuche) 8. [Abgrenzung zur CI/CD-Pipeline](#8-abgrenzung-zur-cicd-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` – nur `web`, darüber ist der Browser-Zugriff erreichbar (Port 3000). - `backend-net` – `web` und `api`, Kommunikation zwischen Frontend-Server und Backend. - `data-net` – `api` und `db`, als `internal: true` markiert. Die Datenbank ist damit aus dem Host-Netzwerk grundsätzlich nicht erreichbar; Zugriff nur über `docker compose exec db ...` von innen. **Startreihenfolge:** `db` → `api` → `web`, erzwungen über `depends_on` mit `condition: service_healthy`: - `db`: Healthcheck `pg_isready -U tessera` (Intervall 5 s, 5 Versuche). - `api`: Healthcheck `wget --spider http://localhost:3001/health` (Intervall 10 s, 3 Versuche, `start_period` 10 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 ps` zeigt `web` daher 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. 1. Repository auf den Server holen: ```bash git clone /schalli/tessera-ctl.git cd tessera-ctl ``` 2. `.env` aus der Vorlage anlegen: ```bash cp .env.prod.example .env ``` Die Vorlage `.env.prod.example` enthält die für den Produktivbetrieb relevanten Variablennamen; `.env.example` ist die schlankere Entwicklungsvariante. Welche Variablen zu setzen sind, steht in Kapitel [3. Konfiguration](#3-konfiguration). Mindestens erforderlich, bevor irgendetwas startet: - `DB_PASSWORD` und dazu passend `DATABASE_URL` - `JWT_SECRET` - `TESSERA_ADMIN_EMAIL`, `TESSERA_ADMIN_PASSWORD` - `TESSERA_ENCRYPTION_KEY` (siehe Warnhinweis unten – ohne diesen Wert startet nichts) 3. Encryption-Key erzeugen und eintragen: ```bash openssl rand -hex 32 ``` Das Ergebnis (64 Hex-Zeichen) als `TESSERA_ENCRYPTION_KEY=` in die `.env` eintragen. 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.yml` verwendet die Compose-Syntax `${TESSERA_ENCRYPTION_KEY:?...}`, wodurch bereits `docker compose` selbst mit einer Fehlermeldung abbricht, bevor ein Container startet, falls die Variable fehlt. Zusätzlich prüft `CryptoService` (`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. 4. Container starten: ```bash docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d ``` Beim ersten Start seedet die API (`AdminSeedService`, `apps/api/src/user/admin-seed.service.ts`) automatisch den initialen Super-Admin-Account aus `TESSERA_ADMIN_USER` / `_EMAIL` / `_PASSWORD` sowie den Mandanten `default`. 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 `.env` danach ändert. 5. Prüfen, ob alles läuft: ```bash docker compose -f docker-compose.prod.yml ps curl -s http://localhost:3001/health ``` `api` sollte als `healthy` markiert 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:@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: ```bash 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: ```bash 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: ```bash 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 ```bash 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 ```bash 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`, Mount `pgdata:/var/lib/postgresql/data` im `db`-Container). Dieses Volume ist der einzige daürhafte Datenspeicher, der über ein `docker volume`-Backup zusätzlich gesichert werden könnte. - **Verschlüsselungsschlüssel** `TESSERA_ENCRYPTION_KEY`: liegt nur in `.env` auf dem Host, **nicht** im Datenbank-Dump. Getrennt sichern (siehe Kapitel 2/3) – ohne ihn sind alle per `pg_dump` gesicherten verschlüsselten Zugangsdaten wertlos. - **Hochgeladene Dateien** (Avatare unter `user-files/avatars/`, generierte DKV-Exporte unter `user-files/`, siehe `apps/api/src/user/user.controller.ts` und `apps/api/src/dkv/dkv-export.service.ts`): Diese Dateien landen im Verzeichnis `user-files/` **innerhalb** des `api`-Containers. Weder `docker-compose.yml` noch `docker-compose.prod.yml` mounten dafür ein Docker-Volume oder ein Host-Verzeichnis – der Ordner existiert ausschließlich in der beschreibbaren Container-Schicht. **Das bedeutet: Bei jedem `--force-recreate` von `api` gehen 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: ```bash 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`](./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.