390 lines
16 KiB
Markdown
390 lines
16 KiB
Markdown
# 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
|
||
|
||
Drei Dinge, und nur diese drei:
|
||
|
||
| | Was | Wo |
|
||
|---|---|---|
|
||
| **1** | **Die Datenbank.** 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` |
|
||
| **3** | **Die Anhänge.** Die Dateien und Fotos an den Schritten. | Volume `laufzettel_anhaenge` · Nativ: `ANHAENGE_PFAD` |
|
||
|
||
> **Die Anhänge sind seit #032 dabei, und sie sind die Falle.** In der Datenbank stehen nur ihre
|
||
> **Metadaten** — Name, Typ, Größe, Prüfsumme. Die Dateien selbst liegen im Dateisystem. Eine
|
||
> Sicherung, die nur `pg_dump` macht, sieht vollständig aus und ist es nicht: nach dem Zurückspielen
|
||
> stehen die Anhänge in der Oberfläche, und jeder Klick darauf meldet, dass die Datei fehlt.
|
||
> Datenbank und Verzeichnis gehören **zum selben Zeitpunkt** gesichert.
|
||
|
||
**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:** 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`.
|
||
|
||
#### Und die Anhänge
|
||
|
||
Hier gilt das Umgekehrte: ein Dateiabzug ist genau richtig, denn es sind gewöhnliche Dateien.
|
||
|
||
```bash
|
||
docker run --rm \
|
||
-v laufzettel_anhaenge:/daten:ro \
|
||
-v "$PWD":/ziel \
|
||
debian:13 tar czf /ziel/anhaenge-$(date +%F).tar.gz -C /daten .
|
||
```
|
||
|
||
Kurz nachsehen, dass etwas drin ist:
|
||
|
||
```bash
|
||
tar tzf anhaenge-$(date +%F).tar.gz | head -5
|
||
tar tzf anhaenge-$(date +%F).tar.gz | wc -l
|
||
```
|
||
|
||
Ist die Datei leer und es gibt Anhänge in der Oberfläche, stimmt der Volume-Name nicht — `docker
|
||
volume ls | grep anhaenge` nennt den richtigen. Er trägt den Projektnamen als Präfix, und der kommt
|
||
aus dem Verzeichnisnamen.
|
||
|
||
### 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. Die Anhänge zurück ins Volume – **vorher leeren**, sonst bleiben Dateien
|
||
# liegen, auf die keine Zeile mehr zeigt
|
||
docker run --rm -v laufzettel_anhaenge:/daten debian:13 sh -c 'rm -rf /daten/* /daten/.[!.]*' || true
|
||
docker run --rm \
|
||
-v laufzettel_anhaenge:/daten \
|
||
-v "$PWD":/quelle:ro \
|
||
debian:13 tar xzf /quelle/anhaenge-2026-09-24.tar.gz -C /daten
|
||
|
||
# 5. Migrationen nachziehen – nötig, wenn der Abzug von einer älteren Fassung stammt
|
||
docker compose run --rm migrate pnpm exec prisma migrate deploy
|
||
|
||
# 6. 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 die Anhänge dazu — gewöhnliche Dateien, also `tar`:
|
||
|
||
```bash
|
||
# Der Pfad steht in ANHAENGE_PFAD; ohne Angabe liegt er unter /opt/laufzettel/daten/anhaenge.
|
||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||
sudo tar czf /var/backups/laufzettel-anhaenge-$(date +%F).tar.gz -C "$ANHAENGE" .
|
||
```
|
||
|
||
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. Die Anhänge zurück – **vorher leeren**, sonst bleiben Dateien liegen, auf
|
||
# die keine Zeile mehr zeigt
|
||
ANHAENGE=$(sudo sh -c '. /etc/laufzettel/laufzettel.env; echo "${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"')
|
||
sudo rm -rf "$ANHAENGE"
|
||
sudo install -d -o laufzettel -g laufzettel -m 0750 "$ANHAENGE"
|
||
sudo tar xzf /var/backups/laufzettel-anhaenge-2026-09-24.tar.gz -C "$ANHAENGE"
|
||
sudo chown -R laufzettel:laufzettel "$ANHAENGE"
|
||
|
||
# 5. Migrationen nachziehen, wenn der Abzug älter ist als der Code
|
||
sudo bash /opt/laufzettel/deploy/install.sh
|
||
|
||
# 6. Wieder anfahren und nachsehen
|
||
sudo systemctl start laufzettel
|
||
curl -s http://127.0.0.1:3000/api/gesundheit
|
||
```
|
||
|
||
Schritt 5 ist der Installationsaufruf, weil er die Migrationen mitbringt und den Dienst gleich
|
||
neu startet — Schritt 6 ist dann nur noch die Gegenprobe.
|
||
|
||
**Der `chown` in Schritt 4 ist nicht Beiwerk.** `tar` legt die Dateien mit den Besitzern aus dem
|
||
Archiv an; läuft die Rücksicherung als `root`, gehören sie danach `root`, und der Dienstnutzer
|
||
`laufzettel` kommt nicht mehr an sie heran. Jeder Anhang meldet dann „Datei fehlt“, obwohl sie
|
||
daliegt. 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
|
||
# `root`, nicht `postgres`: der Timer sichert jetzt auch das Anhang-Verzeichnis,
|
||
# und das gehoert dem Dienstnutzer `laufzettel`. Der Datenbankteil laeuft
|
||
# weiterhin als `postgres`, ueber `runuser`.
|
||
User=root
|
||
ExecStart=/usr/local/sbin/laufzettel-sichern
|
||
ExecStartPost=/usr/bin/find /var/backups -name 'laufzettel-*' -mtime +30 -delete
|
||
```
|
||
|
||
`/usr/local/sbin/laufzettel-sichern`, Modus 0750:
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# Datenbank und Anhaenge zum **selben** Zeitpunkt. Getrennt gesichert ergeben
|
||
# beide Haelften kein vollstaendiges Bild (#032).
|
||
set -Eeuo pipefail
|
||
tag=$(date +%F)
|
||
runuser -u postgres -- pg_dump -Fc laufzettel > "/var/backups/laufzettel-$tag.dump"
|
||
|
||
# shellcheck source=/dev/null
|
||
. /etc/laufzettel/laufzettel.env
|
||
anhaenge="${ANHAENGE_PFAD:-/opt/laufzettel/daten/anhaenge}"
|
||
if [ -d "$anhaenge" ]; then
|
||
tar czf "/var/backups/laufzettel-anhaenge-$tag.tar.gz" -C "$anhaenge" .
|
||
fi
|
||
```
|
||
|
||
`/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
|
||
```
|
||
|
||
Einrichten, einschalten, und **einmal von Hand auslösen** — eine Sicherung, die
|
||
nur im Timer steht, hat noch nie eine Datei erzeugt:
|
||
|
||
```bash
|
||
sudo install -m 0750 laufzettel-sichern /usr/local/sbin/laufzettel-sichern
|
||
sudo install -d -o postgres -g postgres -m 0700 /var/backups
|
||
sudo systemctl daemon-reload
|
||
sudo systemctl enable --now laufzettel-sicherung.timer
|
||
sudo systemctl start laufzettel-sicherung.service
|
||
ls -l /var/backups/laufzettel-*
|
||
sudo systemctl list-timers laufzettel-sicherung.timer
|
||
```
|
||
|
||
Nach dem Lauf müssen **zwei** Dateien daliegen: `laufzettel-<Datum>.dump` und
|
||
`laufzettel-anhaenge-<Datum>.tar.gz`. Fehlt die zweite, gibt es entweder keine Anhänge, oder
|
||
`ANHAENGE_PFAD` zeigt woandershin als gedacht.
|
||
|
||
### 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 die Anhänge direkt danach, damit beide Hälften vom selben Zeitpunkt stammen:
|
||
|
||
```cron
|
||
20 3 * * * docker run --rm -v laufzettel_anhaenge:/daten:ro -v /var/backups:/ziel debian:13 tar czf /ziel/laufzettel-anhaenge-$(date +\%F).tar.gz -C /daten . && find /var/backups -name 'laufzettel-anhaenge-*' -mtime +30 -delete
|
||
```
|
||
|
||
### 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.
|
||
|
||
### Und die Anhänge mitprüfen
|
||
|
||
Die Zahl in der Datenbank gegen die Zahl der Dateien halten — passt sie nicht, fehlt eine der beiden
|
||
Hälften:
|
||
|
||
```bash
|
||
# Compose: Zeilen in der Datenbank …
|
||
docker compose exec -T postgres psql -U laufzettel laufzettel -tAc 'SELECT count(*) FROM "Anhang"'
|
||
# … gegen Dateien im Archiv
|
||
tar tzf anhaenge-2026-09-24.tar.gz | grep -c '\.' || true
|
||
```
|
||
|
||
Stimmen die Zahlen nicht überein, ist entweder der Abzug älter als das Archiv oder umgekehrt. Beides
|
||
zum **selben Zeitpunkt** sichern — am einfachsten im selben Skript, direkt hintereinander.
|
||
|
||
## 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 5 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. |
|