feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme
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:
@@ -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
|
||||
@@ -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).
|
||||
@@ -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. |
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
Reference in New Issue
Block a user