Files
tessera-ctl/docs/anleitung-betrieb.md
T
schalli dab72eb1f9 fix(compose): user-files dauerhaft speichern und Betriebshandbuch nachziehen
- docker-compose.yml und docker-compose.prod.yml mounten /app/user-files
  im Dienst api auf ein neues benanntes Volume user-files (Eigentuemerschaft
  uid 1001 folgt aus dem Image, kein Bind-Mount)
- docker-compose.dev.yml bleibt unveraendert (Compose fuehrt Mount-Listen
  ueber das Ziel zusammen)
- Betriebshandbuch Kapitel 6: Speicher als dauerhaft beschrieben, Mount-Zeile
  woertlich zum Kopieren, Hinweis dass /opt/tessera/docker-compose.yml auf
  dem Server separat gepflegt werden muss (Deploy erreicht sie nicht)
- Betriebshandbuch Kapitel 7: Fehlerzeile zu verschwundenen Avataren/Exporten
  an den reparierten Repository-Stand angepasst

WINDOWS #17 bleibt offen, bis die Serverdatei manuell ergaenzt ist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
2026-09-09 09:34:15 +02:00

347 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# 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 <gitea-url>/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:<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.
<!-- VERIFY: Ob und wie CORS_ORIGIN, NODE_ENV oder weitere in main.ts gelesene Variablen auf dem tatsächlichen Server zusätzlich gesetzt sind, lässt sich aus dem Repository allein nicht feststellen. -->
**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). `pgdata` ist eines von zwei dauerhaften Docker-Volumes (siehe
nächster Punkt) und kann zusätzlich über ein `docker volume`-Backup gesichert
werden.
- **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 liegen im benannten
Docker-Volume `user-files`, gemountet auf `/app/user-files` im Dienst `api`. Der
Mount ist in `docker-compose.yml` und `docker-compose.prod.yml` eingetragen:
```yaml
# beim Dienst api:
- user-files:/app/user-files
# im Top-Level-Block volumes:
user-files:
```
Damit überstehen Avatare und DKV-Exporte ein `--force-recreate` von `api`. Wie bei
`pgdata` zeigt `docker volume ls` das Volume mit vorangestelltem Projektnamen an
(`<projekt>_user-files`). Gesichert werden die Dateien weiterhin mit
`docker compose cp api:/app/user-files ./user-files-backup`, alternativ über eine
Sicherung des Docker-Volumes selbst (wie bei `pgdata`).
**Wichtig für den Betrieb auf einem bestehenden Server:** `/opt/tessera/docker-compose.yml`
ist keine Arbeitskopie dieses Repositorys – die Datei wurde dort von Hand
bearbeitet und weicht ab. Ein Deploy holt ausschließlich Images und fasst diese
Datei nicht an. Diese Compose-Änderung erreicht eine bereits laufende Installation
deshalb **nicht von selbst**. Wer die Reparatur dort haben will, trägt dieselben
zwei Zeilen selbst in `/opt/tessera/docker-compose.yml` ein (vorher sichern) und
erstellt die Container einmal neu. Bis dahin gilt für die laufende Installation
weiterhin der alte, verlustbehaftete Zustand – prüfbar mit `docker inspect
tessera-api-1` und einem Blick auf `Mounts`.
## 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 | Die verwendete Compose-Datei mountet `user-files/` nicht als Volume – im Repository-Stand seit dieser Version behoben, betrifft nur eine Installation mit abweichender Compose-Datei | Die zwei Zeilen aus Kapitel 6 in die verwendete Compose-Datei eintragen (auf dem Server: `/opt/tessera/docker-compose.yml`, vorher sichern) und `api` neu erstellen. |
## 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.