feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Zwei Wege in den Betrieb, beide so weit gefahren, wie es hier moeglich war.

deploy/install.sh richtet Debian 13 in neun Schritten ein: PostgreSQL 17 hoert
nur am Unix-Socket, der Dienstnutzer hat kein Login, die Geheimnisse entstehen
im Zielsystem und liegen 0600 bei root. Die Umgebungsdatei wird nie
ueberschrieben -- auch nicht beim Aktualisieren, sonst enden alle Sitzungen und
die verschluesselten Bind-Passwoerter waeren unlesbar.

deploy/proxmox/laufzettel.sh legt den Container an und ruft install.sh darin
auf. Der pct-Teil ist ungetestet, weil kein PVE-Host zur Verfuegung stand; die
Gegenprobe steht als Pruefliste in docs/betrieb/proxmox.md, das Folge-Issue ist
#024.

Die Abnahme gegen alle 61 Kriterien hat drei Zusagen ohne Deckung gefunden, alle
behoben: der Healthcheck des Webdienstes konnte nie bestehen (busybox loest
localhost nach ::1 auf, Next lauscht nur auf IPv4), die CI-Pipeline konnte in
keinem Job gruen werden (kein LDAP-Dienst, kein gestarteter Server, kein
shellcheck), und der README nannte fuer die Entwicklungsdatenbank den falschen
Port.

Closes #19
This commit is contained in:
Florian Wilde
2026-09-26 15:53:01 +02:00
parent fbbb634e56
commit bf1e52cf73
20 changed files with 3663 additions and 103 deletions
+67
View File
@@ -0,0 +1,67 @@
# Laufzettel betreiben
Diese Sammlung richtet sich an den, der Laufzettel betreibt, aber nicht gebaut hat. Sie führt von
einem leeren Server bis zur ersten Anmeldung, und danach durch Aktualisierung, Sicherung und
Fehlersuche. Wer die Anwendung *benutzen* will, liest stattdessen die
[Anleitung](../anleitung/README.md).
Es gibt zwei Wege in den Betrieb. Beide sind gleichwertig und führen zum selben Stand.
| Weg | Für wen | Was du mitbringen musst |
|---|---|---|
| **[Docker Compose](inbetriebnahme.md)** | Es gibt schon einen Docker-Host, und weitere Dienste laufen dort ebenso. Aktualisieren heißt neu bauen. | Docker mit Compose-Plugin |
| **[Nativ im Proxmox-LXC](proxmox.md)** | Es gibt einen Proxmox-Host und keinen Docker. Ein Skript legt den Container an und installiert darin alles — PostgreSQL, Node, die Anwendung, einen systemd-Dienst. | Proxmox VE 8 oder 9, oder ein leeres Debian 13 |
Nimm den Compose-Weg, wenn du schwankst: er ist der Weg, gegen den hier entwickelt und getestet
wird. Der `pct`-Teil des Proxmox-Skripts ist ungetestet — das steht so, und warum, in
[`proxmox.md`](proxmox.md).
## Was auf beiden Wegen gilt
Vier Dinge sind keine Empfehlung, sondern Teil des Aufbaus. Sie zu übergehen heißt, einen anderen
Stand zu betreiben als den beschriebenen.
**Die Datenbank ist von außen nicht erreichbar.** Im Compose-Stand hat `postgres` kein
`ports:`-Mapping und hängt nur im Netz `intern`, das `internal: true` ist — ein Netz ohne Gateway.
Nativ läuft PostgreSQL mit `listen_addresses = ''`, also nur am Unix-Socket. Dieselbe Regel, zwei
Mittel. Daraus folgt, dass jede Sicherung und jeder Blick in die Daten über den Dienstnamen
beziehungsweise den Socket läuft, nie über `psql -h localhost`. `tests/betriebsstand.test.ts` hält
das für den Compose-Stand fest, damit ein `ports:`-Mapping nicht versehentlich zurückkommt.
**Die Anwendung bindet auf `127.0.0.1`** und spricht kein HTTPS. Davor gehört ein Reverse Proxy, der
das Zertifikat hält — siehe [`reverse-proxy.md`](reverse-proxy.md). Ohne Proxy erreicht die
Anwendung niemand außer dem Host selbst, und das ist die richtige Voreinstellung: ein Dienst, der
aus Versehen im LAN steht, fällt keinem auf.
**`APP_URL` ist die öffentliche Adresse und wird nicht geraten.** Aus ihr entstehen die QR-Adressen
auf den gedruckten Aufklebern und die OIDC-Rücksprungadresse. Sie später zu ändern ist möglich, aber
teuer — der Grund steht am Anfang von [`reverse-proxy.md`](reverse-proxy.md). Leg die Adresse fest,
bevor der erste Aufkleber gedruckt wird.
**Geheimnisse entstehen im Zielsystem.** `APP_SECRET`, das Datenbank-Passwort und das erste
Admin-Passwort werden dort erzeugt, wo sie gelten, und stehen in keinem Repository und in keiner
Vorlage. `APP_SECRET` gehört mit in die Sicherung: ohne ihn sind nach dem Zurückspielen alle
Sitzungen ungültig *und* die verschlüsselt gespeicherten LDAP-Bind-Passwörter unlesbar. Siehe
[`sicherung.md`](sicherung.md).
## Die Kapitel
- **[Inbetriebnahme mit Docker Compose](inbetriebnahme.md)** — von null bis zur ersten Anmeldung,
mit der vollständigen Tabelle der Umgebungsvariablen, dem Seed und der Aktualisierung
- **[Der native Weg im Proxmox-LXC](proxmox.md)** — `deploy/proxmox/laufzettel.sh` und
`deploy/install.sh`, was sie anlegen, wie ein zweiter Aufruf aktualisiert, und die Prüfliste für
den ersten echten Lauf
- **[Reverse Proxy](reverse-proxy.md)** — Caddy und nginx als vollständiges Beispiel, TLS,
Weiterleitungen, welche Kopfzeilen nötig sind und welche schaden
- **[Anmeldung über authentik](authentik.md)** — OIDC-Provider, Gruppen auf Rollen, Notfallzugang
- **[Sichern und Zurückspielen](sicherung.md)** — auf beiden Wegen, was in die Sicherung gehört und
was nicht, und wie du prüfst, dass das Zurückspielen wirklich funktioniert
## Wo sonst noch etwas steht
- [`../../README.md`](../../README.md) — Kurzfassung für Entwickler, Betriebsmodi, Seed-Inhalt
- [`../../.env.example`](../../.env.example) — jede Umgebungsvariable mit Kommentar, zum Kopieren
- [`../architektur.md`](../architektur.md) — warum es so gebaut ist; Abschnitt 6 fasst die
Betriebsregel zusammen
- [`../anleitung/08-fehlerbehebung.md`](../anleitung/08-fehlerbehebung.md) — Fehlermeldungen der
Oberfläche. Wer eine Meldung im Browser sieht, ist dort besser bedient als hier
+260
View File
@@ -0,0 +1,260 @@
# Inbetriebnahme mit Docker Compose
Von einem Host mit Docker bis zur ersten Anmeldung. Rechne mit zwanzig Minuten, davon zehn für den
ersten Bau des Abbilds.
Der andere Weg — nativ in einem Proxmox-LXC, ohne Docker — steht in [`proxmox.md`](proxmox.md).
## 1. Voraussetzungen
- Docker mit dem Compose-Plugin (`docker compose version` muss antworten, nicht `docker-compose`)
- 2 GB RAM und 8 GB Platte reichen; der erste Bau braucht kurzzeitig mehr
- `git`, `openssl`
- ein Reverse Proxy für TLS — auf demselben Host oder davor, siehe
[`reverse-proxy.md`](reverse-proxy.md)
- ein DNS-Name, der auf den Proxy zeigt, **bevor** du anfängst: er wird zu `APP_URL`
Node und pnpm brauchst du **nicht** auf dem Host. Beides steckt im Abbild.
## 2. Quellcode holen
```bash
sudo mkdir -p /opt/laufzettel
sudo chown "$USER" /opt/laufzettel
git clone https://git.wilde-server.de/florian.wilde/Laufzettel.git /opt/laufzettel
cd /opt/laufzettel
```
Das Verzeichnis ist frei wählbar; alle folgenden Befehle laufen darin, weil `docker compose` die
`docker-compose.yml` und die `.env` im aktuellen Verzeichnis sucht.
## 3. Die `.env` anlegen
```bash
cp .env.example .env
openssl rand -base64 48 # das wird APP_SECRET
openssl rand -base64 24 # das wird POSTGRES_PASSWORD
```
Dann `.env` öffnen und mindestens diese fünf Werte setzen:
```dotenv
APP_URL=https://laufzettel.firma.de
APP_SECRET=<Ausgabe von openssl rand -base64 48>
POSTGRES_PASSWORD=<Ausgabe von openssl rand -base64 24>
SEED_ADMIN_EMAIL=admin@firma.de
SEED_ADMIN_PASSWORD=<ein Passwort, das du gleich brauchst>
```
Und für die erste Anmeldung, bis OIDC steht:
```dotenv
AUTH_LOCAL_ENABLED=true
```
> **`APP_URL` ist die öffentliche Adresse, nicht die interne.** Hinter einem Reverse Proxy ist es
> dessen Adresse mit `https://`, nicht `http://127.0.0.1:3000`. Aus ihr entstehen die QR-Adressen auf
> den gedruckten Aufklebern; sie später zu ändern kostet einen Stapel neuer Aufkleber. Der Grund
> steht ausführlich in [`reverse-proxy.md`](reverse-proxy.md).
> **Fehlen `APP_URL`, `APP_SECRET` oder `POSTGRES_PASSWORD`, startet der Stand gar nicht.** Das ist
> Absicht: `docker compose` bricht mit `APP_SECRET fehlt in der .env` ab, statt mit einem
> Standardwert zu laufen, den jeder kennt.
**`DATABASE_URL` lässt du für den Compose-Stand in Ruhe.** Die Adresse zur Datenbank baut sich im
Container immer aus `POSTGRES_USER`, `POSTGRES_PASSWORD` und `POSTGRES_DB` zusammen, mit dem
Dienstnamen `postgres` als Host. Der Wert in der `.env` gilt nur für Werkzeuge, die *außerhalb* der
Container laufen — `pnpm dev`, `pnpm prisma …` von einer Entwicklungsmaschine aus. Bis zur Abnahme
von #019 las der Compose-Stand ihn mit, und wer dem dokumentierten Weg folgte, bekam einen Stand, der
nicht startet: in der `.env.example` steht dort `localhost`, und im Container zeigt das ins Leere.
### Alle Umgebungsvariablen
Jede steht mit Kommentar in [`../../.env.example`](../../.env.example). **Pflicht** sind `APP_URL`,
`APP_SECRET` und `POSTGRES_PASSWORD`.
| Variable | Bedeutung |
|---|---|
| `APP_URL` | Die **öffentliche** Adresse der Anwendung, mit Schema und ohne Schrägstrich am Ende. Steckt in den QR-Codes der Aufkleber und in der OIDC-Rücksprungadresse. Entscheidet außerdem, ob das Sitzungs-Cookie das `Secure`-Flag bekommt (bei `https://` ja). |
| `APP_SECRET` | Schlüssel für die Sitzungs-Cookies und für die Verschlüsselung des LDAP-Bind-Passworts. Mindestens 32 Zeichen, erzeugen mit `openssl rand -base64 48`. **Ändert man ihn, sind alle Sitzungen ungültig und gespeicherte Bind-Passwörter unlesbar.** Gehört in die Sicherung. |
| `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | Zugangsdaten der Datenbank im Container. Nach dem ersten Start liegen sie im Volume; sie nachträglich in der `.env` zu ändern ändert die Datenbank **nicht** und macht den Stand nur unerreichbar. |
| `DATABASE_URL` | Nur für Werkzeuge außerhalb der Container. Der Compose-Stand baut seine Adresse selbst; siehe oben. |
| `BIND_ADDR`, `APP_PORT` | Wohin der Webdienst auf dem Host gebunden wird. Standard `127.0.0.1:3000` — davor gehört ein Reverse Proxy. Nur wenn der Proxy auf einem **anderen** Rechner läuft, gehört hier die LAN-Adresse des Hosts hin, nie `0.0.0.0` ohne Firewall. |
| `AUTH_LOCAL_ENABLED` | `true` schaltet die Anmeldung mit E-Mail und Passwort frei. Für die erste Anmeldung und als Notfallzugang, wenn der Identitätsanbieter ausfällt. Auf `false` verschwindet das Formular **und** der Endpunkt lehnt ab. |
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | Der Identitätsanbieter. Leer heißt: kein OIDC. Discovery über `<issuer>/.well-known/openid-configuration`. |
| `OIDC_NAME` | Beschriftung des Anmeldeknopfs, etwa „Firmenanmeldung“. |
| `OIDC_SCOPE` | Angefragte Bereiche. Der Standard `openid profile email groups` passt zu [`authentik.md`](authentik.md); nur ändern, wenn der Anbieter die Gruppen unter einem anderen Bereich liefert. |
| `OIDC_GROUP_ADMIN`, `OIDC_GROUP_EDITOR`, `OIDC_GROUP_TECHNICIAN`, `OIDC_GROUP_VIEWER` | Welche Gruppe welche Rolle bekommt. Die Rolle wird bei **jeder** Anmeldung neu gesetzt; eine hier in der Oberfläche geänderte Rolle überschreibt der nächste Login. |
| `OIDC_CUSTOMER_GROUP_PREFIX` | Nur im Modus `DIENSTLEISTUNG`: Gruppen mit diesem Präfix (etwa `lz-kunde-MUS`) binden einen Benutzer an einen Kunden. Leer heißt: keine Einschränkung. |
| `ZEITPLAN_AKTIV` | `false` schaltet den Verzeichnis-Abgleich nach Zeitplan ab. Der Zeitplan läuft im Prozess der Anwendung und sieht jede Minute nach, welche Quelle fällig ist. |
| `APP_VERSION`, `GIT_SHA` | Erscheinen unten links in der Oberfläche. Im Compose-Stand sind es Build-Argumente — sie wirken erst beim nächsten `--build`. |
| `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` | Der Administrator, den der Seed anlegt. Das Passwort wird nur als argon2-Hash gespeichert. Fehlt eines, überspringt der Seed ihn — und dann kommst du nicht hinein. |
| `SEED_MODE` | `INTERN` (Standard) oder `DIENSTLEISTUNG`, `SERVICE` als Alias. Betrifft nur den **ersten** Seed-Lauf; danach steht der Modus in der Datenbank und wird in der Oberfläche umgeschaltet. |
| `SEED_ORG_NAME`, `SEED_ORG_KUERZEL` | Name und Kürzel der eigenen Organisation. Das Kürzel (2 bis 6 Großbuchstaben oder Ziffern) steht auf Etiketten und in der Hostname-Regel. |
| `TEST_DATABASE_URL` | Nur für die Tests. Im Betrieb ohne Bedeutung. |
## 4. Starten
```bash
docker compose up -d --build
```
Was dabei passiert, in dieser Reihenfolge:
1. `postgres` startet und wird über `pg_isready` als gesund gemeldet
2. `migrate` läuft **einmal** und spielt `prisma migrate deploy` ein, dann beendet es sich
3. `web` startet, sobald `migrate` sauber durch ist
Ein beendeter `migrate`-Container ist also kein Fehler, sondern das erwartete Ergebnis. Nachsehen:
```bash
docker compose ps
docker compose logs -f web
```
## 5. Den Seed einmal von Hand fahren
**Der Seed läuft absichtlich nicht automatisch.** Ein Einmal-Dienst, der bei jedem Start Daten
anlegt, ist ein Dienst, der irgendwann Daten anlegt, die niemand wollte. Du startest ihn selbst,
genau einmal:
```bash
docker compose run --rm migrate pnpm db:seed
```
Der Seed legt an:
| | Inhalt |
|---|---|
| Grundbestand | Betriebsmodus, eigene Organisation, Administrator aus `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` |
| Vorlagen | die sieben Abläufe als **veröffentlichte Version 1** — PC-Neuinstallation, Onboarding, Offboarding, Server-Inbetriebnahme, Firewall-Rollout, Patchday, Restore-Test |
| Beispielbestand | 3 Abteilungen, 5 Personen, `PC-001` und `NB-001` mit Tag-Code, 5 vorgedruckte Tags, Schnellstart-Vorgaben je Gerätetyp |
| Verzeichnis | eine **deaktivierte** Beispielquelle zum Abschauen — Adresse und Bind-Passwort ersetzen, dann einschalten |
**Er richtet ein, er räumt nicht auf.** Ein zweiter Lauf ändert nichts: eine bearbeitete Vorlage,
eine geänderte Ziel-OU oder ein umgeschalteter Betriebsmodus überstehen ihn. Du kannst ihn also
gefahrlos wiederholen, wenn du nicht sicher bist, ob er durchgelaufen ist.
Mit `SEED_MODE=DIENSTLEISTUNG` kommen zwei Demo-Kunden dazu. Willst du ohne Beispieldaten starten,
setz `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` und lösch die Beispiele hinterher in der
Oberfläche — die Vorlagen lässt du besser stehen, sie sind der eigentliche Wert.
## 6. Erste Anmeldung
Erst prüfen, ob die Anwendung überhaupt antwortet — vom Host, denn nach außen bindet sie nicht:
```bash
curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"ok"}
```
Antwortet das mit `{"status":"fehler",…}` und Status 503, erreicht die Anwendung die Datenbank nicht.
Kommt gar nichts, läuft `web` nicht — `docker compose logs web`.
Dann den Reverse Proxy einrichten ([`reverse-proxy.md`](reverse-proxy.md)), `https://laufzettel.firma.de`
öffnen und mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORD` anmelden.
Danach der Reihe nach:
1. **Admin → Einstellungen** — Betriebsmodus prüfen, Organisationsname und Kürzel
2. **[authentik anbinden](authentik.md)** und `AUTH_LOCAL_ENABLED` wieder auf `false` setzen — bis
auf ein einziges lokales Admin-Konto mit Passwort im Passwortmanager, für den Fall, dass der
Anbieter ausfällt
3. **Sicherung einrichten**, bevor echte Daten drin sind: [`sicherung.md`](sicherung.md)
Wie es weitergeht, steht in der [Anleitung](../anleitung/README.md).
## Warum die Datenbank zu ist, und was daraus folgt
Der Aufbau ist bewusst so und in `tests/betriebsstand.test.ts` festgehalten:
- `postgres` hat **kein** `ports:`-Mapping, nur `expose` — auf dem Host gibt es keinen Port 5432
- `postgres` hängt ausschließlich im Netz `intern`, das `internal: true` ist: ein Netz ohne Gateway.
Weder Host noch LAN kommen hinein, und die Datenbank kommt auch nicht heraus
- nur `web` hängt zusätzlich im Netz `extern`, weil es den Reverse Proxy, den Identitätsanbieter und
das LDAP-Verzeichnis erreichen muss
- `web` bindet auf `127.0.0.1:3000`
Ein Datenbankport, den es nicht gibt, muss nicht mit einem Passwort verteidigt werden. Das ist die
ganze Begründung.
Daraus folgt dreierlei, und das ist der Teil, der im Alltag zählt:
**Kein `psql` vom Host.** `psql -h localhost -p 5432` findet nichts. Wer in die Daten sehen will,
geht durch den Container:
```bash
docker compose exec postgres psql -U laufzettel laufzettel
```
**Die Sicherung läuft über den Dienstnamen**, nicht über einen Port. Der vollständige Weg steht in
[`sicherung.md`](sicherung.md):
```bash
docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz
```
**Externe Werkzeuge kommen nicht daran** — kein pgAdmin vom Arbeitsplatz, kein
Monitoring-Agent per TCP. Wer das braucht, öffnet den Port nicht, sondern legt einen SSH-Tunnel auf
den Host und von dort `docker compose exec`. Ein `ports:`-Mapping in die `docker-compose.yml` zu
schreiben lässt den Test rot werden; das ist sein Zweck.
Prüfen lässt sich beides in zwei Zeilen:
```bash
nc -z localhost 5432 && echo "FEHLER: offen" || echo "dicht"
docker compose exec web nc -z postgres 5432 && echo "von innen erreichbar"
```
**`docker-compose.dev.yml` ist nicht für den Betrieb.** Diese Datei öffnet PostgreSQL auf
`127.0.0.1:5434` und benutzt das Passwort `laufzettel`. Sie ist für die Entwicklung da und hat auf
einem Server nichts zu suchen.
## Aktualisieren
```bash
cd /opt/laufzettel
git pull
docker compose up -d --build
```
Die Migrationen laufen dabei von selbst: `migrate` startet vor `web` und läuft durch, bevor die
Anwendung hochkommt. Der Seed läuft **nicht** mit — richtig so, er würde nichts ändern, aber er
gehört nicht in eine Aktualisierung.
Vor dem Aktualisieren eine Sicherung ziehen. Eine Migration ist nicht rückwärts fahrbar.
Zwischen zwei Versionen lohnt ein Blick in [`../../CHANGELOG.md`](../../CHANGELOG.md).
## Anhalten, neu starten, wegräumen
```bash
docker compose restart web # nur die Anwendung neu starten, etwa nach .env-Änderungen
docker compose down # alles anhalten, Daten bleiben im Volume
docker compose down -v # löscht das Volume und damit die Datenbank
```
> **`down -v` löscht die Datenbank.** Das Volume heißt `laufzettel_datenbank`, und mit ihm gehen alle
> Läufe, alle Vorlagen und alle Tag-Codes. Ein vergebener Tag-Code wird nie wiederverwendet — nach
> einem `down -v` ohne Sicherung sind alle gedruckten Aufkleber wertlos, weil ihre Codes in der
> neuen Datenbank nicht existieren.
Eine Änderung an der `.env` greift erst nach `docker compose up -d`; `restart` allein liest die
Datei nicht neu. Änderungen an `APP_VERSION` oder `GIT_SHA` sind Build-Argumente und brauchen
`--build`.
## Wenn es klemmt
| Symptom | Ursache und was zu tun ist |
|---|---|
| `APP_SECRET fehlt in der .env` beim `up` | Genau das. Der Stand bricht absichtlich ab, statt mit einem Standardwert zu starten. Gilt ebenso für `APP_URL` und `POSTGRES_PASSWORD`. |
| `web` startet nicht, `migrate` steht auf `exited (1)` | `docker compose logs migrate`. Meist erreicht die Migration die Datenbank nicht — oder du hast `POSTGRES_PASSWORD` nach dem ersten Start geändert. Das Passwort steckt dann noch im Volume. |
| `/api/gesundheit` antwortet mit 503 | Die Anwendung läuft, die Datenbank antwortet nicht. `docker compose ps` — ist `postgres` gesund? |
| Der Seed bricht mittendrin ab | Bis zur Abnahme von #019 fehlte dem Einmal-Dienst `APP_SECRET`, und das Bind-Passwort der Beispiel-Verzeichnisquelle ließ sich nicht verschlüsseln — abgebrochen wurde erst, nachdem die Vorlagen schon standen. Steht dein `docker-compose.yml` auf dem aktuellen Stand, ist das behoben; ein erneuter Lauf holt den Rest nach. |
| Nach dem Login wieder auf der Anmeldeseite | Das Cookie ist `Secure`, die Verbindung aber `http`. `APP_URL` auf `https://…` setzen und HTTPS über den Proxy anbieten. |
| Die QR-Codes zeigen auf `localhost:3000` | `APP_URL` ist nicht oder falsch gesetzt. Die Oberfläche warnt unter **Admin** und in der Etikettenansicht davor. Aufkleber, die so gedruckt wurden, sind wertlos — bevor du weiterdruckst, `APP_URL` richtigstellen und `docker compose up -d`. |
| Jeder Benutzer bekommt `LESER` | Der Claim `groups` fehlt im Token. Siehe [`authentik.md`](authentik.md), Abschnitt 8. |
| Keine Knöpfe tun etwas, die Seite sieht aber richtig aus | Du siehst einen Entwicklungsstand, der nicht hydriert. Im Compose-Stand kann das nicht passieren — dort läuft immer der gebaute Stand. Prüf, ob du tatsächlich den Compose-Stand vor dir hast und nicht ein `pnpm dev` auf demselben Host. |
Fehlermeldungen, die in der Oberfläche erscheinen, stehen in
[`../anleitung/08-fehlerbehebung.md`](../anleitung/08-fehlerbehebung.md).
+286
View File
@@ -0,0 +1,286 @@
# Der native Weg: Proxmox-LXC und Debian 13
Ohne Docker. Ein Skript legt auf dem Proxmox-Host einen unprivilegierten Debian-13-Container an und
installiert darin alles: PostgreSQL 17 nur am Unix-Socket, Node 24, die Anwendung als
systemd-Dienst. Wer schon ein leeres Debian 13 hat — als LXC, als virtuelle Maschine oder auf Blech —
überspringt den ersten Teil und ruft `deploy/install.sh` direkt auf.
Der andere Weg, mit Docker Compose, steht in [`inbetriebnahme.md`](inbetriebnahme.md).
> **Der Proxmox-Teil ist nicht automatisch geprüft.** Für diese Arbeit stand kein PVE-Host zur
> Verfügung; alles, was `pct` anfasst, ist geschrieben und gelesen, aber nie gelaufen.
> `deploy/install.sh` dagegen ist geprüft — es installiert in einem `debian:13`-Abbild vollständig
> durch und bringt mit `--test` seine eigene Selbstprüfung mit. Was du beim ersten echten Lauf
> nachsehen musst, steht am Ende dieser Seite in einer Prüfliste. Das ist keine Floskel: geh sie
> durch.
## Was am Ende auf dem System steht
| Ort | Was |
|---|---|
| `/opt/laufzettel` | Der Quellcode als Git-Arbeitsverzeichnis, Eigentümer `laufzettel`. Aktualisiert wird mit `git pull`, deshalb ein Checkout und kein Tarball. |
| `/opt/laufzettel/.next/standalone` | Der gebaute Server. Von hier startet `node server.js`. |
| `/etc/laufzettel/laufzettel.env` | **Die Geheimnisse dieser Installation**: `APP_SECRET`, `DATABASE_URL` mit Passwort, `APP_URL`. Modus 0600, Eigentümer root. Das Skript überschreibt diese Datei nie — auch nicht beim Aktualisieren. |
| `/etc/systemd/system/laufzettel.service` | Der Dienst. Läuft als `laufzettel`, mit `NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und `Restart=on-failure`. |
| `/var/cache/laufzettel` | Heimatverzeichnis des Dienstnutzers — nur der Zwischenspeicher von pnpm und corepack, kein Arbeitskonto. |
| `/etc/postgresql/17/main/conf.d/laufzettel.conf` | Eine Zeile: `listen_addresses = ''`. |
| Datenbank `laufzettel` | Rolle `laufzettel` mit erzeugtem Passwort, erreichbar nur über den Socket `/run/postgresql`. |
Der Dienstnutzer `laufzettel` ist ein Systemkonto mit `/usr/sbin/nologin` als Shell und ohne
Heimatverzeichnis unter `/home`. Anmelden kann sich damit niemand.
## Weg A: der One-Liner auf dem Proxmox-Host
In der Shell des PVE-Hosts, als root:
```bash
bash -c "$(curl -fsSL https://git.wilde-server.de/florian.wilde/Laufzettel/raw/branch/main/deploy/proxmox/laufzettel.sh)"
```
Das Skript prüft zuerst, ob es auf einem PVE 8 oder 9 als root läuft, holt bei Bedarf die
Debian-13-Vorlage über `pveam`, und fragt dann per Dialog: **Standard** oder **Erweitert**.
**Standard** nimmt diese Werte, ohne zu fragen:
| | |
|---|---|
| CTID | die nächste freie |
| Hostname | `laufzettel` |
| Vorlage | Debian 13 |
| Kerne | 2 |
| RAM | 2 GB |
| Platte | 8 GB |
| Swap | 512 MB |
| Privilegien | unprivilegiert, mit `nesting=1` — die systemd-Unit braucht es |
| Netz | DHCP auf `vmbr0` |
Das ist auf einen kleinen Betrieb zugeschnitten und reicht für einige Tausend Läufe. Der erste Bau
braucht kurzzeitig mehr RAM als der Betrieb — wenn `pnpm build` mit `Killed` abbricht, ist das die
Ursache; dann im Erweitert-Dialog 4 GB geben und den Wert danach zurückdrehen.
**Erweitert** fragt dieselben Werte einzeln ab, dazu eine feste IP statt DHCP, die Bridge und die
beiden Speicher getrennt: einen für das Wurzeldateisystem des Containers (Inhalt `rootdir`) und
einen für die Debian-Vorlage (Inhalt `vztmpl`). Vorbelegt ist jeweils der erste aktive Speicher, der
den Inhalt aufnehmen kann; findet das Skript keinen, bricht es mit dieser Begründung ab.
Ein **zweiter Aufruf** erkennt einen vorhandenen Container am Hostnamen und bietet an, ihn zu
aktualisieren, statt einen zweiten anzulegen.
Das Skript installiert selbst nichts. Es legt den Container an, holt `deploy/install.sh` hinein und
ruft es dort auf — damit der Weg mit und ohne Proxmox nicht auseinanderläuft.
| Schalter | Bedeutung |
|---|---|
| `--dry-run` (auch `--trockenlauf`) | Gibt jeden `pct`-, `pveam`- und `pvesh`-Aufruf nur aus. Legt nichts an, lädt nichts, und überspringt auch die Prüfung auf root und PVE — läuft also auf jedem Rechner. |
| `--standard` | Ohne Dialog, alle Standardwerte nehmen. |
| `--erweitert` | Direkt in den Erweitert-Dialog. |
| `--aktualisieren` | Nur aktualisieren, ohne Rückfrage. Bricht ab, wenn es keinen Container mit dem Hostnamen `laufzettel` gibt — es wird also keiner angelegt. Gedacht für den zweiten und jeden weiteren Aufruf, etwa aus einem Wartungsfenster heraus. |
| `--zweig <name>` | Git-Zweig, aus dem `install.sh` geholt wird. Standard `main`. |
| `--hilfe` | Alle Schalter. |
### Erst einmal nur hinsehen
```bash
bash laufzettel.sh --dry-run
```
Nimm das beim ersten Mal: der ausgegebene `pct create` lässt sich lesen, prüfen und notfalls von Hand
abschicken. Der Trockenlauf braucht keinen PVE-Host und läuft bis zum Abschlussbildschirm durch.
## Weg B: `install.sh` in einem vorhandenen Debian 13
Ob das Debian in einem LXC steckt, in einer VM oder auf Blech, ist dem Skript gleich. Es braucht
root, `apt` und Internet.
```bash
apt install -y git
git clone https://git.wilde-server.de/florian.wilde/Laufzettel.git /tmp/laufzettel-quelle
sudo bash /tmp/laufzettel-quelle/deploy/install.sh --url https://laufzettel.firma.de --admin-mail admin@firma.de
```
Die Schalter:
| Schalter | Bedeutung |
|---|---|
| `--url <adresse>` | Die **öffentliche** Adresse, also `APP_URL`. Ohne Angabe nimmt das Skript `http://<erste IP des Systems>:<port>` — das läuft, aber die QR-Codes auf den Aufklebern tragen dann diese Adresse. Setz sie richtig, bevor du druckst. |
| `--admin-mail <adresse>` | Kennung des ersten Administrators. Standard `admin@firma.example`. |
| `--port <nummer>` | Port des Webdienstes. Standard 3000. |
| `--bind <adresse>` | Wohin gebunden wird. Standard `127.0.0.1` — davor gehört ein Reverse Proxy. Läuft der Proxy auf einem anderen Rechner, gehört hier die IP des Containers hin, dazu eine Firewall-Regel. |
| `--modus <wert>` | `INTERN` (Standard) oder `DIENSTLEISTUNG`. |
| `--zweig <name>`, `--quelle <url\|pfad>` | Anderer Zweig oder andere Git-Quelle. Für Testläufe nimmt `--quelle` auch einen lokalen Pfad, wenn dort ein Git-Arbeitsverzeichnis liegt. |
| `--passwort-datei <pfad>` | Legt das beim ersten Lauf erzeugte Administrator-Passwort **zusätzlich** in diese Datei (0600, `root`), in den Zeilen `SEED_ADMIN_EMAIL=` und `SEED_ADMIN_PASSWORD=`. Gedacht für Aufrufer, die die Ausgabe nicht mitlesen können — genau so holt sich der Proxmox-Weg das Passwort für seinen Abschlussbildschirm. **Wer den Schalter benutzt, löscht die Datei nach dem Ablesen**, sonst liegt das Passwort dauerhaft im Klartext im System. |
| `--test` | Nach der Installation eine Selbstprüfung fahren, siehe unten. |
| `--hilfe` | Alle Schalter. |
Dieselben Werte nimmt das Skript auch aus der **Umgebung** — für Aufrufer ohne Befehlszeile, also
`pct exec` im Proxmox-Weg. Ein Schalter schlägt die Umgebung. Die Namen sind dieselben wie in
`.env.example`, damit es nur eine Schreibweise gibt: `APP_URL`, `BIND_ADDR`, `APP_PORT`,
`SEED_ADMIN_EMAIL`, `SEED_MODE`, dazu `LAUFZETTEL_REPO` (wie `--quelle`), `LAUFZETTEL_ZWEIG` (wie
`--zweig`) und `LAUFZETTEL_PASSWORT_DATEI` (wie `--passwort-datei`).
> **`--url`, `--port`, `--bind`, `--admin-mail` und `--modus` wirken nur beim ersten Lauf.** Danach
> steht die Wahrheit in `/etc/laufzettel/laufzettel.env`, und diese Datei wird nie überschrieben. Ein
> zweiter Aufruf mit anderen Werten warnt und ignoriert sie. Zum Ändern: die Datei bearbeiten und
> `systemctl restart laufzettel`.
Das Skript läuft in neun Schritten und sagt bei jedem, was es tut. Rechne mit fünf bis fünfzehn
Minuten, das meiste davon `pnpm install` und `pnpm build`.
## Der Abschlussbildschirm
Auf Weg A siehst du **zwei** Abschlüsse: erst den von `install.sh` aus dem Container, dann den des
Proxmox-Skripts, das noch einmal Adresse, Kennung und Passwort zeigt und sagt, wie du in den
Container kommst (`pct enter <ctid>`).
**Das erzeugte Admin-Passwort steht in `laufzettel.env` absichtlich nicht** — dort liegen nur
`APP_SECRET` und das Datenbankpasswort. Damit der Abschlussbildschirm es trotzdem zeigen kann,
obwohl `install.sh` hinter `pct exec` läuft, gibt das Skript ihm `LAUFZETTEL_PASSWORT_DATEI` mit:
`install.sh` legt das Passwort einmalig in `/root/laufzettel-erstpasswort` (0600, `root`), der
Aufrufer liest es von dort und **löscht die Datei sofort wieder**, gelesen oder nicht. Beim zweiten
Aufruf — beim Aktualisieren — entsteht kein neues Passwort, und der Abschluss zeigt an dieser Stelle
„unverändert (das Konto bestand schon)“. Das ist der Normalfall, kein Fehler.
Die Kennung dagegen wird aus `laufzettel.env` gelesen; steht dort nichts, zeigt der Abschluss den
Verweis auf die Datei statt einer Adresse.
Der Abschluss von `install.sh` sieht so aus:
```
Laufzettel ist eingerichtet.
Adresse https://laufzettel.firma.de
Dienst horcht 127.0.0.1:3000
Administrator admin@firma.de
Passwort xY7k2mQ4pL9wR3tN8vZa
Dieses Passwort wird genau hier einmal angezeigt. In der Datenbank steht
nur der argon2-Hash, in /etc/laufzettel/laufzettel.env steht es nicht.
```
> **Schreib das Passwort auf, bevor du das Fenster schließt.** Ohne `--passwort-datei` steht es in
> keiner Datei — weder in `laufzettel.env` noch im Journal — und es wird nie ein zweites Mal
> angezeigt. Ist es weg, hilft nur ein neues über `psql` beziehungsweise, wenn OIDC schon läuft, der
> Weg über den Identitätsanbieter.
Bestand das Admin-Konto schon — also beim zweiten Lauf — erzeugt das Skript kein neues Passwort und
schreibt an dieser Stelle `Passwort unveraendert (Konto bestand schon)`. Das ist der
Normalfall beim Aktualisieren.
Der lokale Login ist auf diesem Weg von Anfang an eingeschaltet
(`AUTH_LOCAL_ENABLED="true"` in der Umgebungsdatei), denn sonst käme niemand hinein. Sobald OIDC
steht, setz ihn auf `false` — bis auf das eine Notfallkonto, siehe
[`authentik.md`](authentik.md), Abschnitt 7.
## Was danach zu tun ist
1. **Reverse Proxy davorstellen.** Der Dienst bindet auf `127.0.0.1` und spricht kein HTTPS; ohne
Proxy erreicht ihn nur der Container selbst. Siehe [`reverse-proxy.md`](reverse-proxy.md).
2. **Anmelden** und unter **Admin → Einstellungen** Organisationsname und Kürzel setzen. Das Skript
schreibt `SEED_ORG_NAME="Eigene Organisation"` und `SEED_ORG_KUERZEL="EIGEN"` — das Kürzel steht
später auf Etiketten, es lohnt sich, es früh richtigzustellen.
3. **[authentik anbinden](authentik.md)**: die `OIDC_*`-Zeilen in
`/etc/laufzettel/laufzettel.env` sind auskommentiert vorbereitet. Ausfüllen,
`systemctl restart laufzettel`.
4. **Sicherung einrichten**, bevor echte Daten drin sind: [`sicherung.md`](sicherung.md).
## Den Dienst bedienen
```bash
systemctl status laufzettel
systemctl restart laufzettel # nach jeder Änderung an laufzettel.env
journalctl -u laufzettel -f # Protokoll mitlesen
journalctl -u laufzettel -n 100 --no-pager
```
Eigene Zusätze zur Unit gehören nach `/etc/systemd/system/laufzettel.service.d/` als Drop-in.
Änderungen direkt in `laufzettel.service` überschreibt der nächste Lauf des Installationsskripts.
Nachsehen, ob die Anwendung antwortet:
```bash
curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"ok"}
```
In die Datenbank sehen — über den Socket, denn einen Port gibt es nicht:
```bash
sudo -u postgres psql laufzettel
```
## Aktualisieren
Dasselbe Skript noch einmal aufrufen:
```bash
sudo bash /opt/laufzettel/deploy/install.sh
```
Es holt den Quellcode (`git pull --ff-only`), baut neu, spielt die Migrationen ein, fährt den Seed
(der nichts ändert, was schon da ist) und startet den Dienst neu. **Geheimnisse und Daten bleiben:**
`laufzettel.env` wird nicht angefasst, die Datenbank bleibt stehen, das Admin-Passwort wird nicht neu
erzeugt.
Vorher eine Sicherung ziehen. Eine Migration fährt nicht rückwärts.
Schlägt `git pull --ff-only` fehl, liegen in `/opt/laufzettel` eigene Änderungen. Nachsehen mit:
```bash
sudo -u laufzettel git -C /opt/laufzettel status
```
## Die Selbstprüfung
```bash
sudo bash /opt/laufzettel/deploy/install.sh --test
```
Prüft vier Dinge und nennt jedes einzeln mit `[ok]` oder `[FEHLER]`:
1. `ss -ltn` zeigt **keinen** Port 5432 — PostgreSQL spricht kein TCP
2. Die Datenbank ist über den Socket erreichbar, Migration und Seed sind gelaufen (mindestens sieben
Vorlagen)
3. `/etc/laufzettel/laufzettel.env` hat `600 root:root`
4. `/api/gesundheit` antwortet mit `status ok`
Das ist der schnellste Weg, nach einem Umzug oder einer Aktualisierung zu prüfen, dass der Stand noch
der beschriebene ist.
## Was du beim ersten Lauf selbst nachprüfen musst
Der `pct`-Teil ist ungetestet — kein PVE-Host stand zur Verfügung. Diese Liste ist die Gegenprobe.
Geh sie in dieser Reihenfolge durch; jede Zeile nennt den Schritt, das erwartete Ergebnis und was zu
tun ist, wenn es abweicht.
| Schritt | Erwartet | Wenn es abweicht |
|---|---|---|
| `bash laufzettel.sh --dry-run` auf dem PVE-Host | Ein vollständiger `pct create`-Aufruf mit CTID, Vorlage, `--cores 2`, `--memory 2048`, `--rootfs …:8`, `--unprivileged 1`, `--net0 name=eth0,bridge=vmbr0,ip=dhcp`. Es wird **nichts** angelegt. | Fehlt ein Wert oder steht ein leerer Platzhalter im Aufruf, den Befehl **nicht** von Hand abschicken. Stattdessen den Container über die PVE-Oberfläche anlegen und Weg B gehen. |
| Vorlage vorhanden | `pveam list local` nennt ein `debian-13-standard`-Archiv. Fehlt es, holt das Skript es mit `pveam download`. | Findet `pveam available` keine Debian-13-Vorlage, ist der Vorlagenindex alt: `pveam update`. Danach noch einmal. |
| `pct create` läuft durch | Der Container erscheint in `pct list` mit dem Hostnamen `laufzettel`. | Meldet PVE „storage does not support container directories“, ist der gewählte Speicher kein Verzeichnis- oder ZFS-Speicher. Im Erweitert-Dialog einen anderen wählen (`local-lvm`, `local-zfs`). |
| Der Container bekommt eine Adresse | `pct exec <ctid> -- ip -4 addr` zeigt eine Adresse auf `eth0`, und `pct exec <ctid> -- ping -c1 deb.debian.org` kommt durch. | Ohne DHCP im Netz bleibt die Adresse leer und die Installation scheitert beim ersten `apt`. Container anhalten, im Erweitert-Dialog eine feste IP samt Gateway setzen. |
| `install.sh` läuft im Container | Neun Schritte, jeder mit Meldung, am Ende der Abschlussbildschirm mit Adresse, Kennung und Passwort. | Bricht ein Schritt ab, steht die Ursache in der Meldung. Der Lauf ist wiederholbar: `pct enter <ctid>`, dann `bash /opt/laufzettel/deploy/install.sh` erneut. |
| **Kein TCP-Port 5432** | `pct exec <ctid> -- ss -ltn` zeigt 5432 **nicht**. | Steht der Port da, hat `listen_addresses = ''` nicht gegriffen: prüfen, ob `/etc/postgresql/17/main/postgresql.conf` die Zeile `include_dir = 'conf.d'` enthält, dann `systemctl restart postgresql@17-main`. |
| Der Dienst läuft | `pct exec <ctid> -- systemctl is-active laufzettel` sagt `active`, und `curl` auf `/api/gesundheit` antwortet `{"status":"ok"}`. | `pct exec <ctid> -- journalctl -u laufzettel -n 50 --no-pager`. Häufigste Ursache: der Bau ist an zu wenig RAM gescheitert und `.next/standalone` ist unvollständig. |
| Umgebungsdatei geschützt | `stat -c '%a %U:%G' /etc/laufzettel/laufzettel.env` → `600 root:root`. | Rechte richtigstellen: `chown root:root`, `chmod 0600`. Und prüfen, wer in der Zwischenzeit lesen konnte. |
| Neustart übersteht alles | `pct reboot <ctid>`, danach antwortet `/api/gesundheit` ohne Zutun wieder. | Kommt der Dienst nicht von selbst, fehlt das `enable`: `systemctl enable laufzettel` und `systemctl enable postgresql@17-main`. |
| Zweiter Lauf aktualisiert | `install.sh` noch einmal: „laufzettel.env ist vorhanden und bleibt unangetastet“, „Datenbank laufzettel vorhanden — Daten bleiben, wie sie sind“, Passwort „unverändert“. | Wird ein **neues** Admin-Passwort angezeigt, war das Konto nicht auffindbar — dann steht in `laufzettel.env` eine andere `SEED_ADMIN_EMAIL` als erwartet. Nachsehen, bevor du weitermachst. |
| **Der Dienst ist von außen erreichbar** | `curl http://<container-ip>:3000/api/gesundheit` vom PVE-Host aus antwortet `{"status":"ok"}`. | Antwortet nur `curl` **im** Container, aber nicht von außen, steht in `/etc/laufzettel/laufzettel.env` noch `HOSTNAME="127.0.0.1"`. Das ist die richtige Voreinstellung für einen Proxy im selben Container, aber nicht für einen Proxy davor: auf `0.0.0.0` setzen (oder auf die Container-IP), `systemctl restart laufzettel`, und eine Firewall-Regel, die den Port nur dem Proxy erlaubt. |
| **`APP_URL` passt zum Namen, unter dem du später zugreifst** | In `laufzettel.env` steht die öffentliche Adresse. Ohne `--url` setzt `install.sh` `http://<Container-IP>:3000`. | Richtigstellen, **bevor** der erste Aufkleber gedruckt wird: Datei bearbeiten, `systemctl restart laufzettel`. Siehe [`reverse-proxy.md`](reverse-proxy.md). |
| Die Anwendung ist bedienbar | Anmelden, einen Lauf öffnen, **einen Schritt abhaken**, Seite neu laden, Haken ist noch da. | Bleibt der Haken nicht, ist der Reverse Proxy falsch eingestellt — mit hoher Wahrscheinlichkeit die `Host`-Kopfzeile. Siehe [`reverse-proxy.md`](reverse-proxy.md). |
| Die Sicherung funktioniert | Einmal `pg_dump` ziehen und in eine zweite, leere Datenbank zurückspielen. | Siehe [`sicherung.md`](sicherung.md). Eine Sicherung, die nie zurückgespielt wurde, ist keine. |
Der letzte Punkt ist der wichtigste. Alles davor kann man reparieren.
## Wenn es klemmt
| Symptom | Ursache und was zu tun ist |
|---|---|
| „Das Skript … braucht root“ | Mit `sudo bash install.sh` aufrufen, nicht ohne. |
| „Erwartet ist Debian 13, gefunden …“ | Nur eine Warnung, der Lauf geht weiter. Auf älteren Debians kann die PostgreSQL-Fassung abweichen; das Skript nimmt dann das Metapaket `postgresql`. |
| „runuser fehlt (Paket util-linux)“ | Ein sehr schlankes Abbild. `apt install util-linux`. |
| „/opt/laufzettel ist nicht leer, enthält aber kein Git-Arbeitsverzeichnis“ | Dort liegt ein alter Stand ohne `.git`. Verschieben oder löschen, dann neu starten. |
| `pnpm build` bricht mit `Killed` ab | Zu wenig RAM. Dem Container vorübergehend 4 GB geben (`pct set <ctid> --memory 4096`), erneut laufen lassen, danach zurückdrehen. |
| „Der Dienst antwortet nach 60 s nicht“ | `journalctl -u laufzettel -n 50 --no-pager`. Meist fehlt etwas in `laufzettel.env` oder die Datenbank ist nicht erreichbar. |
| „In `laufzettel.env` fehlt `DATABASE_URL`, `APP_SECRET` oder `APP_URL`“ | Die Datei ist beschädigt. Ergänzen ist der richtige Weg. Löschen legt sie neu an — dabei geht das bisherige `APP_SECRET` verloren: alle Sitzungen enden, und die verschlüsselt gespeicherten LDAP-Bind-Passwörter sind unlesbar und müssen neu eingegeben werden. |
| `psql: FATAL: password authentication failed` | Das Passwort in `DATABASE_URL` passt nicht zur Rolle. Das Skript setzt es nur dann neu, wenn es die Umgebungsdatei selbst neu angelegt hat. Von Hand: `sudo -u postgres psql -c '\password laufzettel'` und den Wert in `laufzettel.env` nachziehen. |
| Kein systemd im Container | Das Skript erkennt es, schreibt die Unit trotzdem und sagt, wie der Dienst von Hand zu starten ist. Tritt bei einem `docker run debian:13` auf, nicht in einem LXC. |
+239
View File
@@ -0,0 +1,239 @@
# Reverse Proxy
Laufzettel spricht selbst kein HTTPS und bindet standardmäßig auf `127.0.0.1:3000`. Davor gehört ein
Reverse Proxy, der das Zertifikat hält und die Anfragen weiterreicht. Das gilt auf beiden
Betriebswegen — im [Compose-Stand](inbetriebnahme.md) wie [nativ im LXC](proxmox.md).
## Zuerst: die Adresse, die auf dem Aufkleber landet
`APP_URL` ist die **öffentliche** Adresse der Anwendung. Sie wird nicht aus der eingehenden Anfrage
erraten, sondern gesetzt, und zwar aus einem Grund, der Papier betrifft:
**Aus `APP_URL` entstehen die QR-Adressen auf den gedruckten Aufklebern.** Im QR steht
`{APP_URL}/t/{tagCode}` — nichts weiter, kein Name, keine Seriennummer. Ein Aufkleber klebt Jahre
am Gerät und lässt sich nicht nachträglich korrigieren. Wäre die Adresse aus der Anfrage geraten,
stünde auf jedem Aufkleber, der über den Proxy hinweg gedruckt wurde, der interne Hostname:
`http://laufzettel-app:3000/t/LZ-7K3F9Q`. Den erreicht ein Telefon im WLAN nie.
Deshalb, bevor der erste Aufkleber gedruckt wird:
1. Den DNS-Namen festlegen, unter dem die Anwendung dauerhaft erreichbar sein soll
2. `APP_URL` genau darauf setzen — mit `https://`, ohne Schrägstrich am Ende
3. Einen Aufkleber probedrucken, mit dem Telefon scannen und schauen, wo man landet
Ändert sich `APP_URL` später doch, ist nicht alles verloren, aber unangenehm:
- **Der Scanner in der Anwendung nimmt den Code weiterhin an**, egal von welchem Host die gescannte
Adresse kommt. Das ist ausdrücklich so gebaut: ein Haus zieht um, ein zweiter Name zeigt auf
dieselbe Anwendung — daran soll der Aufkleber im Regal nicht scheitern.
- **Unter dem QR steht der Code im Klartext** (`LZ-7K3F9Q`) und lässt sich eintippen.
- **Was nicht mehr geht, ist der bequeme Weg:** mit der Kamera-App des Telefons scannen und dem
Link folgen. Der zeigt auf die alte Adresse.
Der einfachste Ausweg ist, den alten Namen im DNS und im Proxy als zweiten `server_name`
beziehungsweise zweiten Host weiterlaufen zu lassen und auf denselben Dienst zu leiten. Der
zweitbeste ist, neu zu drucken. Die Tag-Codes selbst bleiben dabei gültig — ein Code wird nie
geändert und nie neu vergeben.
Die zweite Stelle, an der `APP_URL` zählt, ist die OIDC-Rücksprungadresse: `APP_URL` plus
`/api/auth/oidc/rueckruf`, zeichengenau so beim Anbieter hinterlegt. Siehe
[`authentik.md`](authentik.md).
Und die dritte: aus dem Schema von `APP_URL` entscheidet die Anwendung, ob das Sitzungs-Cookie das
`Secure`-Flag bekommt. Steht dort `http://`, obwohl außen HTTPS läuft, ist das Cookie unnötig
schwach. Steht dort `https://`, während außen nur HTTP läuft, wirft der Browser das Cookie weg und
man landet nach jedem Login wieder auf der Anmeldeseite.
## Caddy
Der kürzere Weg. Caddy holt und erneuert das Zertifikat selbst, leitet HTTP auf HTTPS um und setzt
`X-Forwarded-For`, `X-Forwarded-Proto` und `X-Forwarded-Host` von sich aus richtig.
```caddyfile
laufzettel.firma.de {
encode zstd gzip
reverse_proxy 127.0.0.1:3000 {
# Antwortet die Anwendung hier nicht, ist sie wirklich weg – und nicht nur
# gerade beim Neustart. Der Pfad braucht keine Anmeldung.
health_uri /api/gesundheit
health_interval 30s
}
}
```
Läuft Caddy auf einem anderen Rechner als die Anwendung, muss diese auf eine erreichbare Adresse
binden: `BIND_ADDR` im Compose-Stand beziehungsweise `HOSTNAME` in `/etc/laufzettel/laufzettel.env`
nativ. Dann gehört eine Firewall-Regel dazu, die den Port nur dem Proxy erlaubt.
Übernehmen:
```bash
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
## nginx
Mehr Zeilen, und eine Falle. Die vollständige Datei:
```nginx
# HTTP nur, um auf HTTPS zu leiten – und fuer die ACME-Abfrage von certbot.
server {
listen 80;
listen [::]:80;
server_name laufzettel.firma.de;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 308 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name laufzettel.firma.de;
ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem;
# Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx
# laesst von sich aus nur 1 MB durch und waere damit die erste Wand.
client_max_body_size 8m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# Diese Zeile ist die wichtigste der Datei, siehe unten.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind
# reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab.
proxy_read_timeout 90s;
# Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort
# zusammen, und die Seite erscheint spuerbar spaeter auf einmal.
proxy_buffering off;
}
}
```
Übernehmen:
```bash
sudo nginx -t && sudo systemctl reload nginx
```
Das Zertifikat holt certbot:
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d laufzettel.firma.de
```
### Die Falle: `Host` muss durchgereicht werden
**nginx ersetzt die Kopfzeile `Host` von sich aus** durch das Ziel von `proxy_pass`, also
`127.0.0.1:3000`. Caddy und Traefik tun das nicht, nginx schon.
Das bricht jedes Formular in Laufzettel. Der Grund: Next prüft bei jeder Server Action — und jedes
Speichern ist eine — den `Origin` der Anfrage gegen den Host, unter dem es sich selbst wähnt
(`x-forwarded-host`, sonst `host`). Der Browser schickt `laufzettel.firma.de`, der Server liest
`127.0.0.1:3000`, die beiden passen nicht zusammen, und die Aktion wird als möglicher
CSRF-Versuch abgewiesen. Das ist eine Schutzmaßnahme, die hier auf das falsche Ziel schießt.
Das Heimtückische daran: **Seiten laden weiterhin völlig normal.** Nur kein Haken, kein Speichern,
keine Änderung kommt durch. Wer den Proxy nach einem Screenshot abnimmt, merkt es nicht.
`proxy_set_header Host $host;` und `proxy_set_header X-Forwarded-Host $host;` lösen das. Hinter
einem Proxy, der den öffentlichen Host in `x-forwarded-host` weitergibt, braucht es keine weitere
Einstellung in der Anwendung. Erst wenn ein Proxy das nicht kann, käme
`experimental.serverActions.allowedOrigins` in `next.config.ts` in Frage — der Weg über die
Kopfzeile ist der richtige, weil er den Schutz erhält, statt ihn aufzuweichen.
## Welche Kopfzeilen der Proxy setzen soll
| Kopfzeile | Warum |
|---|---|
| `Host` | Muss der öffentliche Name sein, sonst scheitern alle Server Actions. Bei nginx ausdrücklich setzen. |
| `X-Forwarded-Host` | Dasselbe nochmal, und das ist die Kopfzeile, die Next zuerst liest. |
| `X-Forwarded-Proto` | `https`, damit die Anwendung weiß, dass die Verbindung außen gesichert ist. |
| `X-Forwarded-For` | Die Adresse des Benutzers, für die Fehlersuche im Protokoll des Proxys. |
Alles andere braucht die Anwendung nicht. Sie leitet ihre eigenen Adressen aus `APP_URL` ab, nicht
aus diesen Kopfzeilen — deshalb bleibt sie auch dann brauchbar, wenn ein Proxy sie vergisst; nur die
Server Actions eben nicht.
## Welche Kopfzeilen der Proxy nicht setzen soll
**Die Sicherheitskopfzeilen kommen aus der Anwendung.** Sie schickt `Strict-Transport-Security`,
`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` und eine
`Content-Security-Policy` mit einer Nonce je Anfrage. Setz sie im Proxy nicht noch einmal:
- Eine **zweite CSP** ist nicht die mildere, sondern eine zusätzliche. Browser wenden alle
mitgeschickten Richtlinien an, und es gilt jeweils die strengste Einschränkung. Eine
gutgemeinte zweite CSP schaltet damit zuverlässig die Anwendung ab — und zwar wieder so, dass die
Seite aussieht wie immer und nichts tut.
- **HSTS doppelt** ist harmlos, aber unsauber. Wenn du eine längere Laufzeit willst, ändere sie in
`next.config.ts`, nicht im Proxy.
## Was noch dazugehört
**Einbetten geht nicht.** Die CSP der Anwendung enthält `frame-ancestors 'none'`, dazu
`X-Frame-Options: DENY`. Laufzettel lässt sich nicht in ein Intranet-Portal oder ein Dashboard als
`iframe` einhängen. Verlink es stattdessen.
**Kein Unterpfad.** Laufzettel gehört auf einen eigenen Hostnamen, nicht unter
`https://intranet.firma.de/laufzettel/`. Ein `basePath` ist nicht gesetzt, die Anwendung liefert ihre
eigenen Dateien also unter `/_next/…` aus, und die kämen unter einem Unterpfad nicht an. Die
Adressberechnung für die Aufkleber trägt einen Pfad in `APP_URL` bewusst mit — das schützt gegen den
schlimmsten Fall, macht den Unterpfad aber nicht betriebsfähig.
**Der Healthcheck ist `/api/gesundheit`.** Er braucht keine Anmeldung, prüft die Datenbank mit und
antwortet `{"status":"ok"}` oder mit Status 503. Das ist der richtige Pfad für die Überwachung des
Proxys; `/` leitet ohne Cookie auf die Anmeldeseite um und sagt nichts über den Zustand.
**Die Kamera braucht HTTPS.** Der QR-Scanner in der Anwendung benutzt die Kamera des Geräts, und
Browser geben sie nur über eine gesicherte Verbindung frei (`localhost` ausgenommen). Ohne TLS
scannt am Handy niemand — das ist der praktische Grund, warum der Proxy nicht optional ist.
**Etwas Geduld beim Neustart.** Nach `docker compose up -d` oder `systemctl restart laufzettel`
braucht die Anwendung einige Sekunden. Ein Proxy mit aktivem Healthcheck fängt das ab; ohne
bekommen die ersten Anfragen ein `502`.
## Prüfen, dass es wirklich sitzt
Ein Screenshot beweist hier nichts — genau die Fehler, die hier passieren, lassen die Seite richtig
aussehen. Nachprüfen heißt: einmal etwas ändern.
```bash
# 1. Die Anwendung antwortet dem Proxy
curl -s http://127.0.0.1:3000/api/gesundheit
# 2. Der Proxy reicht durch, mit gueltigem Zertifikat
curl -s https://laufzettel.firma.de/api/gesundheit
# 3. HTTP leitet auf HTTPS
curl -sI http://laufzettel.firma.de | head -1
# 4. Die Sicherheitskopfzeilen kommen genau einmal
curl -sI https://laufzettel.firma.de/anmelden | grep -ci "content-security-policy"
```
Und dann per Hand, im Browser:
1. Anmelden
2. Einen Lauf öffnen und **einen Schritt abhaken**
3. Die Seite neu laden und nachsehen, ob der Haken noch da ist
Schritt 3 ist der eigentliche Test. Bleibt der Haken nicht, ist die `Host`-Kopfzeile falsch.
+289
View File
@@ -0,0 +1,289 @@
# Sichern und Zurückspielen
Laufzettel ist ein Gedächtnis: was in einem archivierten Lauf steht, soll in zehn Jahren noch sagen
können, wer wann was getan hat. Dazu kommt ein handfester Grund, der auf Papier klebt — **jeder
gedruckte Aufkleber zeigt auf einen Tag-Code, der nur in der Datenbank existiert.** Ein Code wird nie
geändert und nie neu vergeben; ist die Datenbank weg, sind alle Aufkleber im Haus wertlos, und es
gibt keinen Weg, sie zurückzurechnen.
Die Sicherung ist auf beiden Betriebswegen eine Sache von zwei Befehlen. Das Zurückspielen ist
ebenfalls einfach, aber nur, wenn man es einmal geübt hat.
## Was gesichert werden muss
Zwei Dinge, und nur diese zwei:
| | Was | Wo |
|---|---|---|
| **1** | **Die Datenbank.** Alles Veränderliche steckt darin: Läufe, Vorlagen, Schrittergebnisse, Kommentare, Geräte, Personen, Tag-Codes, Protokoll. | `pg_dump`, siehe unten |
| **2** | **Die Geheimnisse.** `APP_SECRET` und das Datenbank-Passwort. | Compose: die `.env` · Nativ: `/etc/laufzettel/laufzettel.env` |
**Warum die Geheimnisse dazugehören:** `APP_SECRET` verschlüsselt nicht nur die Sitzungs-Cookies,
sondern auch die gespeicherten LDAP-Bind-Passwörter. Spielst du eine Datenbank in eine Installation
mit neuem `APP_SECRET` zurück, sind alle Sitzungen ungültig — das ist harmlos, man meldet sich neu an
— **und jedes Bind-Passwort der Verzeichnisquellen ist unlesbar**. Der Verzeichnis-Abgleich schlägt
dann fehl, und das Passwort muss von Hand neu eingetragen werden. Merkt man erst, wenn der nächste
Abgleich fällig ist.
Sicher die beiden Dinge getrennt und leg das Geheimnis dahin, wo auch die restlichen Zugangsdaten des
Hauses liegen — in den Passwortmanager. Eine Datei mit `APP_SECRET` neben dem Datenbank-Abzug auf
derselben Netzwerkfreigabe hebt den Zweck der Verschlüsselung auf.
**Was nicht gesichert werden muss:** Die Anwendung hält keinen veränderlichen Zustand auf der Platte.
Kein Upload-Verzeichnis, keine erzeugten Dateien. Der Quellcode kommt aus Git, `node_modules` und
`.next` entstehen beim Bauen neu, das PDF der Anleitung liegt am Release.
**Was der Abzug bewusst nicht enthält:** ein `{{generiertes_passwort}}`. Es wird bei jeder Anzeige neu
erzeugt und nie gespeichert — nicht im Schrittergebnis, nicht im Kommentar, nicht im Protokoll. Wer
ein so gesetztes Passwort braucht, findet es in der Sicherung nicht, und das ist Absicht.
> **Der Abzug enthält personenbezogene Daten.** Namen, E-Mail-Adressen und Abteilungen der
> Mitarbeiter, dazu argon2-Hashes der lokalen Passwörter. Er gehört verschlüsselt aufbewahrt und
> nicht auf eine offene Freigabe.
## Docker Compose
### Sichern
Der Datenbankport ist von außen geschlossen — das ändert nichts, die Sicherung läuft über den
Dienstnamen:
```bash
cd /opt/laufzettel
docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > sicherung-$(date +%F).sql.gz
```
`-T` ist wichtig: ohne den Schalter hängt Docker ein Terminal dazwischen, und das verdirbt die
Ausgabe. Im Ergebnis liegt eine Datei von einigen hundert Kilobyte bis wenigen Megabyte.
Wer lieber das Eigenformat von PostgreSQL will — es lässt sich selektiv und parallel zurückspielen:
```bash
docker compose exec -T postgres pg_dump -U laufzettel -Fc laufzettel > sicherung-$(date +%F).dump
```
Kurz prüfen, dass die Datei nicht leer ist:
```bash
gunzip -c sicherung-$(date +%F).sql.gz | head -5
gunzip -c sicherung-$(date +%F).sql.gz | grep -c 'COPY public'
```
Das Volume heißt `laufzettel_datenbank`. Es mit `docker run --volumes-from` wegzukopieren ist
**keine** gleichwertige Sicherung: ein Dateiabzug eines laufenden PostgreSQL ist im besten Fall
brauchbar und im schlechteren still beschädigt. Nimm `pg_dump`.
### Zurückspielen
Die Datenbank muss dafür leer sein — ein Abzug in eine gefüllte Datenbank gespielt bringt lauter
Fehler über schon vorhandene Tabellen und hinterlässt einen Mischzustand.
```bash
cd /opt/laufzettel
# 1. Die Anwendung anhalten, damit niemand dazwischenschreibt
docker compose stop web
# 2. Datenbank leeren und neu anlegen
docker compose exec -T postgres dropdb -U laufzettel laufzettel
docker compose exec -T postgres createdb -U laufzettel -O laufzettel laufzettel
# 3. Abzug einspielen
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel laufzettel
# 4. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
docker compose run --rm migrate pnpm exec prisma migrate deploy
# 5. Wieder anfahren
docker compose start web
curl -s http://127.0.0.1:3000/api/gesundheit
```
Bei einem `-Fc`-Abzug tritt an die Stelle von Schritt 3:
```bash
docker compose exec -T postgres pg_restore -U laufzettel -d laufzettel --no-owner < sicherung-2026-09-24.dump
```
**Den Seed brauchst du danach nicht.** Er würde nichts kaputt machen — er richtet ein und räumt nicht
auf —, aber alles, was er anlegen würde, steht schon im Abzug.
## Nativ (LXC, VM, Blech)
### Sichern
Über den Unix-Socket, als Systemnutzer `postgres`. Einen TCP-Port gibt es nicht.
```bash
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
```
Und das Geheimnis, einmalig und in den Passwortmanager:
```bash
sudo cat /etc/laufzettel/laufzettel.env
```
`sudo -u postgres` und nicht `psql -h localhost`: die Rolle `laufzettel` hat ein Passwort, aber
`postgres` kommt über die Peer-Authentifizierung am Socket ohne eines hinein — für ein Skript, das
ohne Eingabe laufen soll, ist das der einfachere Weg.
### Zurückspielen
```bash
# 1. Dienst anhalten
sudo systemctl stop laufzettel
# 2. Datenbank leeren und neu anlegen, Eigentümer bleibt die Rolle laufzettel
sudo -u postgres dropdb laufzettel
sudo -u postgres createdb -O laufzettel laufzettel
# 3. Abzug einspielen
sudo -u postgres pg_restore -d laufzettel --no-owner /var/backups/laufzettel-2026-09-24.dump
# 4. Migrationen nachziehen, wenn der Abzug älter ist als der Code
sudo bash /opt/laufzettel/deploy/install.sh
# 5. Wieder anfahren und nachsehen
sudo systemctl start laufzettel
curl -s http://127.0.0.1:3000/api/gesundheit
```
Schritt 4 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
neu startet — Schritt 5 ist dann nur noch die Gegenprobe. Willst du nur die Migrationen:
```bash
cd /opt/laufzettel
sudo -u laufzettel env $(sudo grep -v '^#' /etc/laufzettel/laufzettel.env | xargs) pnpm exec prisma migrate deploy
```
## Automatisch sichern
### Nativ, als systemd-Timer
`/etc/systemd/system/laufzettel-sicherung.service`:
```ini
[Unit]
Description=Laufzettel sichern
After=postgresql.service
[Service]
Type=oneshot
User=postgres
# %i-freies Einzeiler-Skript: pg_dump in eine Datei mit Datum, dann alles
# aelter als 30 Tage weg.
ExecStart=/bin/bash -c 'pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%%F).dump'
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*.dump' -mtime +30 -delete
```
`/etc/systemd/system/laufzettel-sicherung.timer`:
```ini
[Unit]
Description=Laufzettel taeglich sichern
[Timer]
OnCalendar=daily
# Nach einem Ausfall die verpasste Sicherung nachholen, statt sie zu ueberspringen.
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
```
```bash
sudo install -d -o postgres -g postgres -m 0700 /var/backups
sudo systemctl daemon-reload
sudo systemctl enable --now laufzettel-sicherung.timer
sudo systemctl list-timers laufzettel-sicherung.timer
```
Einmal von Hand auslösen und nachsehen, ob wirklich eine Datei entstanden ist:
```bash
sudo systemctl start laufzettel-sicherung.service
ls -l /var/backups/
```
### Compose, als Cron-Eintrag
In der Crontab von root (`sudo crontab -e`):
```cron
15 3 * * * cd /opt/laufzettel && docker compose exec -T postgres pg_dump -U laufzettel laufzettel | gzip > /var/backups/laufzettel-$(date +\%F).sql.gz && find /var/backups -name 'laufzettel-*.sql.gz' -mtime +30 -delete
```
Das `%` muss in einer Crontab als `\%` geschrieben werden, sonst schneidet cron die Zeile ab — der
häufigste Fehler an dieser Stelle, und er fällt erst auf, wenn die Datei fehlt.
### Und dann weg vom Rechner
Eine Sicherung, die auf derselben Platte liegt wie die Datenbank, hilft gegen einen Tippfehler und
gegen nichts sonst. Zieh die Dateien nachts auf ein anderes System — Bordmittel genügen:
```bash
rsync -a --delete /var/backups/ sicherung@nas:/laufzettel/
```
Verschlüsselt, wegen der personenbezogenen Daten. Und behalte mehr als eine: ein Schaden, der drei
Tage unbemerkt bleibt, überschreibt sonst die letzte brauchbare Fassung.
## Einmal üben, sonst gilt es nicht
Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung, sondern eine Datei. Der Seed
liefert eigens eine Vorlage **Restore-Test** mit — nimm sie und mach den Test einmal im Quartal zu
einem Lauf mit Datum und Ergebnis. Dann steht er im Archiv, mit Namen und Zeitpunkt, und niemand muss
sich erinnern, wann er zuletzt geprüft hat.
Der Test selbst, ohne den Betrieb anzufassen — in eine zweite, leere Datenbank:
```bash
# Compose
docker compose exec -T postgres createdb -U laufzettel probe
gunzip -c sicherung-2026-09-24.sql.gz | docker compose exec -T postgres psql -U laufzettel probe
docker compose exec -T postgres psql -U laufzettel probe -c 'SELECT count(*) FROM "Lauf"'
docker compose exec -T postgres dropdb -U laufzettel probe
```
```bash
# Nativ
sudo -u postgres createdb probe
sudo -u postgres pg_restore -d probe --no-owner /var/backups/laufzettel-2026-09-24.dump
sudo -u postgres psql -d probe -c 'SELECT count(*) FROM "Lauf"'
sudo -u postgres dropdb probe
```
Kommt bei `count(*)` eine plausible Zahl heraus, ist die Sicherung brauchbar. Kommt eine Fehlermeldung
oder eine Null, hast du es jetzt erfahren und nicht im Ernstfall.
## Umzug auf einen neuen Server
Derselbe Weg, nur mit einer Zwischenstation:
1. Auf dem alten System `pg_dump` ziehen und `APP_SECRET` sowie die restliche `.env` beziehungsweise
`laufzettel.env` mitnehmen
2. Auf dem neuen System frisch einrichten — [Compose](inbetriebnahme.md) oder
[nativ](proxmox.md)
3. **Vor** dem Zurückspielen `APP_SECRET` in der neuen Umgebungsdatei auf den alten Wert setzen, sonst
sind die LDAP-Bind-Passwörter unlesbar
4. Abzug zurückspielen wie oben, Migrationen nachziehen
5. `APP_URL` prüfen: bleibt der DNS-Name gleich, bleiben die gedruckten Aufkleber gültig. Ändert er
sich, lies den Anfang von [`reverse-proxy.md`](reverse-proxy.md), bevor du umstellst
Die PostgreSQL-Hauptfassung sollte auf dem Zielsystem dieselbe oder eine neuere sein. Ein Abzug aus
17 lässt sich in 18 einspielen, umgekehrt nicht.
## Wenn es klemmt
| Symptom | Ursache und was zu tun ist |
|---|---|
| `the input device is not a TTY` | Beim `docker compose exec` fehlt `-T`. |
| Der Abzug ist wenige Bytes groß | `pg_dump` hat nicht die Datenbank erwischt, sondern eine Fehlermeldung geschrieben. Ohne Umleitung wiederholen und lesen, was es sagt. |
| `ERROR: relation "Benutzer" already exists` | Du spielst in eine gefüllte Datenbank. Erst `dropdb`/`createdb`, siehe oben. |
| `role "laufzettel" does not exist` beim Zurückspielen auf einem anderen System | Der Abzug nennt Eigentümer, die dort fehlen. `pg_restore --no-owner` beziehungsweise `pg_dump --no-owner` beim Erzeugen. |
| Nach dem Zurückspielen schlägt der Verzeichnis-Abgleich fehl | `APP_SECRET` ist nicht mehr dasselbe, und das Bind-Passwort ist unlesbar. Entweder den alten Wert eintragen und neu starten, oder unter **Admin → Verzeichnis** das Bind-Passwort neu setzen. |
| Nach dem Zurückspielen fehlen Tabellen oder Spalten | Der Abzug ist älter als der Code. `prisma migrate deploy` nachziehen, siehe Schritt 4 oben. |
| Die Anwendung startet nach dem Zurückspielen nicht | `docker compose logs web` beziehungsweise `journalctl -u laufzettel -n 50 --no-pager`. Meist läuft noch die Migration, oder sie ist gescheitert. |
| Ein Tag-Code taucht doppelt auf | Kann nach einem Zurückspielen nicht passieren; ein partieller Eindeutigkeitsindex in der Datenbank verhindert es. Siehst du es doch, ist der Abzug nicht sauber eingespielt — Datenbank leeren und noch einmal von vorn. |