16 KiB
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_dumpmacht, 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:
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:
docker compose exec -T postgres pg_dump -U laufzettel -Fc laufzettel > sicherung-$(date +%F).dump
Kurz prüfen, dass die Datei nicht leer ist:
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.
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:
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.
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:
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.
sudo -u postgres pg_dump -Fc laufzettel > /var/backups/laufzettel-$(date +%F).dump
Und die Anhänge dazu — gewöhnliche Dateien, also tar:
# 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:
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
# 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:
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:
[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:
#!/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:
[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:
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):
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:
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:
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:
# 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
# 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:
# 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:
- Auf dem alten System
pg_dumpziehen undAPP_SECRETsowie die restliche.envbeziehungsweiselaufzettel.envmitnehmen - Auf dem neuen System frisch einrichten — Compose oder nativ
- Vor dem Zurückspielen
APP_SECRETin der neuen Umgebungsdatei auf den alten Wert setzen, sonst sind die LDAP-Bind-Passwörter unlesbar - Abzug zurückspielen wie oben, Migrationen nachziehen
APP_URLprüfen: bleibt der DNS-Name gleich, bleiben die gedruckten Aufkleber gültig. Ändert er sich, lies den Anfang vonreverse-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. |