Postgres-Backup für Self-Hosted Apps: pg_dump bis PITR
Logische Dumps, WAL-Archiving, Off-Site-Verschlüsselung & echte Restore-Tests
Warum pg_dump allein nicht reicht
Fast jedes Self-Hosted-Setup beginnt beim Backup gleich: ein pg_dump in einem nächtlichen Cronjob, das Ergebnis irgendwo auf der gleichen Platte. Das ist besser als nichts – aber es deckt nur den einfachsten Fehlerfall ab. Drei Probleme bleiben offen:
- Granularität: Ein täglicher Dump heißt im schlimmsten Fall 24 Stunden Datenverlust. Für eine Wissensdatenbank okay, für eine App mit echten Nutzereingaben nicht.
- Off-Site: Liegt das Backup auf derselben Maschine, ist es bei Plattendefekt, Ransomware oder einem versehentlichen
docker volume rmgenauso weg wie die Datenbank. - Vertrauen: Ein Backup, das nie zurückgespielt wurde, ist eine Vermutung, kein Backup.
Ich zeige hier ein dreistufiges Setup, das genau diese drei Lücken schließt – bewusst pragmatisch, nicht überengineered.
Mein Setup
Eine Django-App, Postgres als postgres:16-alpine im Docker-Container, Daten in einem benannten Volume. Nichts Exotisches – genau das Setup, das die meisten Homelab-Apps fahren:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- pgdata:/var/lib/postgresql/data
- ./wal_archive:/wal_archive
restart: unless-stopped
volumes:
pgdata:
Das ./wal_archive-Bind-Mount brauchen wir erst für Stufe 3 – ich nehme es gleich mit auf, damit der Container dafür nicht neu gestartet werden muss.
Stufe 1: Logischer Dump mit pg_dump
Der logische Dump ist die Basis: portabel, versionsunabhängig genug und ideal für den Normalfall „ich brauche die Datenbank von gestern Nacht zurück“. Das Skript dumpt aus dem Container, komprimiert direkt und räumt alte lokale Kopien weg:
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%F_%H%M)
OUT="/srv/backups/appdb_${STAMP}.sql.gz"
docker exec -t db \
pg_dump --no-owner --no-privileges -U app appdb \
| gzip -9 > "$OUT"
# nur die letzten 7 Tage lokal vorhalten
find /srv/backups -name 'appdb_*.sql.gz' -mtime +7 -delete
--no-owner und --no-privileges erleichtern später den Restore in eine frische DB, in der die Rollen anders heißen können. Dann ab in die Crontab:
30 2 * * * /srv/scripts/pg_backup.sh >> /var/log/pg_backup.log 2>&1
Stufe 2: Off-Site & verschlüsselt mit restic
Lokale Dumps retten dich beim Bedienfehler, nicht beim Hardware- oder Standort-Totalschaden. restic schiebt die Dumps verschlüsselt zu einem entfernten Ziel (S3-kompatibler Bucket, anderer Server, externe Platte) und dedupliziert dabei, sodass du viele Versionen für wenig Speicher vorhältst:
export RESTIC_REPOSITORY="s3:https://s3.example.com/mein-backup-bucket"
export RESTIC_PASSWORD_FILE="/etc/restic/password"
# Bucket-Credentials kommen aus der Umgebung / einem Secrets-File,
# niemals ins Skript hardcoden.
restic backup /srv/backups
# Retention: 7 täglich, 5 wöchentlich, 12 monatlich – Rest wird gelöscht
restic forget \
--keep-daily 7 --keep-weekly 5 --keep-monthly 12 \
--prune
restic verschlüsselt das Repository immer. Aber das Passwort liegt damit zwischen dir und deinen Daten: Geht es verloren, ist das Backup wertlos. Sichere es getrennt vom Backup-Ziel – z. B. im Passwortmanager.
Stufe 3: WAL-Archiving für Point-in-Time-Recovery
Hier wird es interessant. Mit Write-Ahead-Log-(WAL-)Archiving sicherst du nicht nur Snapshots, sondern jede einzelne Transaktion. Damit kannst du auf jeden Zeitpunkt zwischen zwei Base-Backups zurückspielen – etwa „eine Minute, bevor das fehlerhafte Migrationsskript lief“. In der postgresql.conf (bzw. als Command-Args im Container):
wal_level = replica
archive_mode = on
archive_command = 'test ! -f /wal_archive/%f && cp %p /wal_archive/%f'
archive_timeout = 300 # spätestens alle 5 Min ein WAL-Segment schreiben
Dazu regelmäßig ein Base-Backup als Ausgangspunkt – pg_basebackup zieht einen konsistenten Stand des gesamten Clusters:
docker exec -t db \
pg_basebackup -U app -D - -Ft -X fetch \
| gzip > /srv/backups/base_$(date +%F).tar.gz
Restore-Logik: das Base-Backup auspacken, das wal_archive als restore_command eintragen, ein recovery.signal setzen – Postgres spielt dann beim Start die WALs bis zum gewünschten recovery_target_time ein. Den genauen Ablauf gibt es im nächsten Abschnitt.
WAL-Archiving lohnt sich, wenn Datenverlust in Minuten schmerzt. Für ein Blog oder eine Wissensbasis reichen Stufe 1+2 oft völlig. Ehrlich abwägen statt aus Prinzip alles aufzusetzen – PITR will auch betrieben und getestet werden.
Restore: der Teil, den alle überspringen
Der häufigste Restore ist der einfachste: einen logischen Dump in eine frische Datenbank zurückspielen. Erst eine leere DB anlegen, dann den Dump einlesen:
docker exec -i db psql -U app -c 'CREATE DATABASE appdb_restore;'
gunzip -c /srv/backups/appdb_2026-06-11_0230.sql.gz \
| docker exec -i db psql -U app -d appdb_restore
Wichtig: in eine neue DB restoren, nicht über die laufende bügeln. So kannst du den wiederhergestellten Stand erst prüfen und erst danach umschalten. Erst wenn der Restore-Pfad einmal sauber durchgelaufen ist, weißt du, dass dein Dump überhaupt vollständig war.
Der Restore-Test als Cronjob
Das ist der Schritt, der aus „ich habe Backups“ ein „ich habe ein Backup, dem ich vertraue“ macht. Ein Wegwerf-Container zieht den neuesten Dump, spielt ihn ein und prüft mit einem Sanity-Check, ob überhaupt Daten drin sind. Fällt der Test durch, soll es laut sein:
#!/usr/bin/env bash
set -euo pipefail
LATEST=$(ls -t /srv/backups/appdb_*.sql.gz | head -1)
# Wegwerf-Postgres, isoliert vom Produktiv-Container
docker run --rm -d --name pg_verify \
-e POSTGRES_PASSWORD=verify postgres:16-alpine
sleep 10
docker exec -i pg_verify psql -U postgres -c 'CREATE DATABASE verify;'
gunzip -c "$LATEST" | docker exec -i pg_verify psql -U postgres -d verify
# Sanity-Check: hat die wichtigste Tabelle Zeilen?
ROWS=$(docker exec -t pg_verify psql -U postgres -d verify -tAc \
'SELECT count(*) FROM auth_user;')
docker stop pg_verify
if [ "${ROWS//[[:space:]]/}" -gt 0 ]; then
echo "Restore-Test OK ($ROWS Zeilen in auth_user)"
else
echo "Restore-Test FEHLGESCHLAGEN – Backup prüfen!" >&2
exit 1
fi
Dieses Skript wöchentlich per Cron, das Ergebnis in dein Monitoring – und du erfährst von einem kaputten Backup, bevor du es brauchst, nicht mittendrin im Notfall.
Monitoring: weiß ich, wenn's klemmt?
Ein Backup-Job, der still scheitert, ist gefährlicher als gar keiner – weil du dich in Sicherheit wähnst. Die billigste Lösung ist ein Dead-Man-Switch: Das Skript pingt am Ende einen Healthcheck-Dienst an; bleibt der Ping aus, schlägt der Dienst Alarm. Eine Zeile am Skriptende reicht:
# nur bei Erfolg pingen (am Ende des Backup-Skripts, set -e sorgt für Abbruch davor)
curl -fsS -m 10 --retry 3 https://hc.example.com/ping/DEIN-CHECK-UUID > /dev/null
Was ich weggelassen habe
- Streaming-Replikation / Hot-Standby: Hochverfügbarkeit ist ein anderes Problem als Backup. Für ein Homelab ist eine zweite Live-Instanz Overkill – und ersetzt Backups ohnehin nicht (ein
DROP TABLErepliziert mit). - pgBackRest / barman: Mächtige Tools mit eingebautem PITR und Retention. Wer mehrere Cluster betreibt, sollte sie ansehen. Für eine App war mir die Kombi aus pg_dump + restic transparenter und leichter zu debuggen.
- Backup der Secrets: Das beste DB-Backup nützt nichts, wenn
.env, restic-Passwort und Compose-Files nicht ebenfalls (getrennt!) gesichert sind. Das gehört in einen eigenen, ehrlichen Artikel.
Fazit
Drei Stufen, sauber gestaffelt: pg_dump für den Alltag, restic für off-site und verschlüsselt, WAL-Archiving, wenn dir Minuten wehtun. Der eigentliche Gewinn steckt aber nicht im Backup, sondern im automatisierten Restore-Test – er ist der Unterschied zwischen Hoffnung und Gewissheit. Im nächsten Artikel nehme ich mir die Secrets- und Volume-Sicherung vor, damit nicht nur die Datenbank, sondern das ganze Setup reproduzierbar zurückkommt.
Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.