Files
Laufzettel/docs/betrieb/sicherung.md
T
Florian Wilde d97aebed95
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(betrieb): native Installation, Proxmox-Einzeiler und Abnahme (#37)
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
Nummer 38.

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
2026-09-27 13:09:14 +02:00

290 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. |