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,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