Kurz gesagt
Problem: In einem Homelab sammeln sich Dinge, die man nicht neu herunterladen kann: Dokumente, Passwörter, Fotos, jahrelange Sensordaten, die Kopplung jedes Zigbee-Geräts. Eine kaputte Platte, ein schiefgegangenes Update oder ein Ransomware-Angriff, und alles ist weg.
Lösung: Nächtliche Snapshots jedes Dienstes auf eine eigene Platte (Datenbanken als Dumps, Dateien mit rsync-Hardlinks), eine dritte Kopie, die sich ein Laptop auf eine verschlüsselte SSD holt, eine Überwachung, die Alarm schlägt, wenn etwas fehlt, und ein getestetes Restore-Skript.
Getestet mit: etwa zehn Diensten in Docker (Nextcloud, Vaultwarden, Paperless-ngx, Authentik, BookStack, Zigbee2MQTT, Traefik, PostgreSQL für Home Assistant), zwei WordPress-Seiten bei einem Webhoster, Bash + rsync + systemd-Timern, Home Assistant für die Alarme
Backups sind der langweiligste Teil eines Homelabs, bis zu dem Tag, an dem man eins braucht. Ich habe meins Schritt für Schritt aufgebaut, meist nach „was wäre wenn…“-Momenten, und am Ende sind es einfache Bash-Skripte, rsync und systemd-Timer geworden: keine Backup-Software mit eigener Datenbank, die ich erst wiederherstellen müsste. Dieser Artikel zeigt das ganze Setup: was ich sichere, wie die Snapshots funktionieren, warum Datenbanken gedumpt statt kopiert werden, wie die dritte Kopie gegen einen gehackten Server geschützt ist, woran ich jede Nacht erkenne, dass es geklappt hat, und wie eine Wiederherstellung aussieht.
1. Drei Kopien, zwei Platten, eine davon außer Reichweite
Die klassische Regel heißt 3-2-1: drei Kopien deiner Daten, auf zwei verschiedenen Medien, eine davon außer Haus. So sieht meine Version aus, und hier hat sie ihre Grenzen:
| Kopie | Wo | Schützt vor |
|---|---|---|
| 1. Live-Daten | Die SSD des Servers | nichts – das ist, was wir schützen |
| 2. Nächtliche Snapshots | Eine USB-Platte am Server, 14–30 Tage Verlauf | gelöschte oder überschriebene Dateien, kaputte Updates, eine defekte Server-SSD |
| 3. Geholte Kopie | Eine verschlüsselte SSD, die ein Laptop selbst befüllt | eine defekte Backup-Platte, einen gehackten Server, Ransomware, Fehler mit den Snapshots |
Ehrlich gesagt steht die dritte Kopie im selben Haus. Gegen Feuer oder Einbruch hilft sie nicht; dafür müsste die SSD woanders liegen, oder eine verschlüsselte Cloud-Kopie wäre der nächste Schritt. Für alles andere haben diese drei Ebenen bisher jedes echte Problem abgedeckt, das ich hatte.
2. Was gesichert wird
Jeder Dienst hat sein eigenes kleines Skript und seine eigene Uhrzeit, sodass ein fehlschlagender Job die anderen nie blockiert und der schwere Nextcloud-Job zuletzt läuft:
| Dienst | Was gesichert wird | Aufbewahrt | Ausfallzeit |
|---|---|---|---|
| WordPress-Seiten (Webhoster) | Dateien per tar über SSH + MySQL-Dump | 30 Tage | keine |
| Paperless-ngx | PostgreSQL-Dump + alle Dokumente + Suchindex | 30 Tage | keine |
| Authentik (Single Sign-on) | PostgreSQL-Dump + Compose-Ordner inkl. .env | 30 Tage | keine |
| Infrastruktur | Traefik (inkl. Zertifikate), CrowdSec-Konfiguration, Portainer, SSH-Konfiguration, die Backup-Skripte selbst | 60 Tage | Portainer ~1 s pausiert |
| Zigbee2MQTT | Der ganze Datenordner (Netzwerkschlüssel, Gerätedatenbank) | 30 Tage | wenige Sekunden pausiert |
| BookStack-Wiki | MariaDB-Dump + Uploads + .env | 30 Tage | keine |
| PostgreSQL für Home Assistant | Dump jeder Datenbank + Rollen | 14 Tage | keine |
| Vaultwarden | Konsistente SQLite-Kopie + Schlüssel + Anhänge | 30 Tage | keine |
| Nextcloud | Alle Benutzerdateien + PostgreSQL-Dump + Config-Volume | 14 Tage | Wartungsmodus während des Laufs |
Bewusst nicht gesichert: Logs, Caches, Warteschlangen, die Sperrliste von CrowdSec (die baut sich selbst neu auf) und die Live-Datenbankordner. Die sind unbrauchbar, solange die Datenbank läuft; die Dumps sind das Backup.
Tipp: Die eigenen Backups von Home Assistant enthalten eine externe Recorder-Datenbank nicht. Wenn du deinen Verlauf wie ich nach PostgreSQL oder MariaDB verlegt hast, brauchst du einen eigenen Dump davon, sonst sind Jahre an Statistiken mit dem nächsten Plattenausfall weg.
3. Snapshots mit rsync-Hardlinks
Jeder Lauf legt einen Ordner mit Datum an, etwa 2026-10-20_0030. Jeder dieser Ordner sieht aus wie eine vollständige Kopie: du kannst darin stöbern, einzelne Dateien herauskopieren oder alles wiederherstellen. Dateien, die sich seit der letzten Nacht nicht geändert haben, werden aber nicht noch einmal kopiert. Sie sind Hardlinks auf dieselbe Datei im vorherigen Snapshot und belegen keinen zusätzlichen Platz. Genau das macht die rsync-Option --link-dest:
/backup/paperless_backup/
├── 2026-10-19_0030/
│ ├── paperless-db.dump Datenbank-Dump
│ ├── media/ alle Dokumente (größtenteils Hardlinks)
│ ├── data/ Suchindex, Klassifikator
│ ├── compose/ docker-compose.yml, .env
│ └── rsync.log
├── 2026-10-20_0030/
├── latest -> 2026-10-20_0030
└── backup-paperless.sh das Skript kopiert sich selbst hierher
- Einen alten Snapshot zu löschen ist sicher. Eine Datei verschwindet erst von der Platte, wenn der letzte Snapshot gelöscht wird, der auf sie zeigt.
- Das Dateisystem muss Hardlinks können: ext4, XFS oder btrfs. Nicht exFAT oder NTFS – das typische Format einer neuen USB-Platte.
- Hardlinks sind keine zusätzlichen Kopien. Dreißig Snapshots eines unveränderten Dokuments sind trotzdem nur eine Datei auf der Platte. Stirbt dieser Sektor, sind alle dreißig betroffen. Dafür gibt es die dritte Kopie.
- Das Skript kopiert sich selbst neben die Snapshots, damit die Anleitung auf der Backup-Platte liegt, auch wenn der Server weg ist.
4. Datenbanken werden gedumpt, nie kopiert
Wer die Dateien einer laufenden Datenbank kopiert, bekommt ein Abbild eines bewegten Ziels: halb geschriebene Seiten, ein Write-Ahead-Log, das nicht dazu passt. Das lässt sich vielleicht wiederherstellen, vielleicht auch nicht. Ein Dump bittet die Datenbank selbst um einen konsistenten Export, während sie weiterläuft. Und jeder Dump wird direkt nach dem Schreiben geprüft:
| Datenbank | Dump | Prüfung |
|---|---|---|
| PostgreSQL | pg_dump -Fc (komprimiertes Custom-Format) | pg_restore --list muss ihn lesen können |
| MariaDB / MySQL | mariadb-dump / mysqldump, gezippt | gzip -t und die Zeile „Dump completed“ am Ende |
| SQLite (Vaultwarden) | sqlite3 … ".backup" (konsistente Online-Kopie) | PRAGMA integrity_check |
Ein Dump, der seine Prüfung nicht besteht, stoppt den ganzen Job, und der Snapshot der Nacht wird nie zu latest (nächster Abschnitt). Ein kaputtes Backup ist schlimmer als keins, weil du es erst merkst, wenn du es brauchst.
5. Ein Backup-Skript, erklärt
Alle meine Skripte folgen demselben Muster. Hier das für Paperless, leicht gekürzt:
#!/usr/bin/env bash
set -Eeuo pipefail
PROJECT=/srv/docker/paperless # Compose-Ordner
DEST=/backup/paperless_backup
KEEP_DAYS=30
STAMP=$(date +%F_%H%M)
SNAP=$DEST/$STAMP
WORK=$SNAP.partial
fail() { echo "ERROR: $*"; exit 1; }
# 1. nie die Systemplatte füllen, wenn die Backup-Platte fehlt
mountpoint -q /backup || fail "/backup is not mounted"
mkdir -p "$DEST" && chmod 700 "$DEST"
rm -rf "$DEST"/*.partial # Reste eines fehlgeschlagenen Laufs
mkdir -p "$WORK"
LINK=()
[ -d "$DEST/latest" ] && LINK=(--link-dest="$DEST/latest/media")
# 2. Datenbank-Dump + Prüfung
cd "$PROJECT"
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > "$WORK/paperless-db.dump"
docker compose exec -T db pg_restore --list < "$WORK/paperless-db.dump" > /dev/null
|| fail "dump check failed"
# 3. Dateien, unveränderte als Hardlinks auf den letzten Snapshot
rsync -aHAX --numeric-ids "${LINK[@]}" "$PROJECT/data/media/" "$WORK/media/"
# 4. erst jetzt wird der Snapshot echt
mv "$WORK" "$SNAP"
ln -sfn "$STAMP" "$DEST/latest"
cp -f "$0" "$DEST/"
# 5. Aufbewahrung
find "$DEST" -mindepth 1 -maxdepth 1 -type d -name '20??-??-??_????'
-mtime +"$KEEP_DAYS" -exec rm -rf {} +
set -Eeuo pipefail: jeder fehlschlagende Befehl stoppt das Skript. Ein Backup-Skript, das nach einem Fehler weitermacht, erzeugt Backups, die gut aussehen und es nicht sind.mountpoint -q: ist die USB-Platte nicht eingehängt, ist/backupnur ein leerer Ordner auf der Systemplatte. Ohne diese Prüfung füllt die erste Nacht nach einem Plattenausfall deine System-SSD.- Der
.partial-Ordner: der Snapshot entsteht unter einem vorläufigen Namen und wird erst umbenannt, wenn alles geklappt hat. Ein fehlgeschlagener Lauf wird nie zulatest, also verlinkt der nächste Lauf weiter auf den letzten guten. rsync -aHAX --numeric-ids: behält Rechte, Hardlinks, ACLs, erweiterte Attribute und die numerischen Besitzer-IDs. Container laufen oft unter Benutzer-IDs, die es auf dem Host nicht gibt; mit Namen statt Nummern würde eine Wiederherstellung die Besitzer falsch setzen.- Aufbewahrung nach Ordneralter:
find -mtime +30entfernt Snapshots, die älter als 30 Tage sind. Dank der Hardlinks ist das immer sicher.
6. Zeitplan mit systemd-Timern
Ich nutze systemd-Timer statt cron. Sie protokollieren jeden Lauf im Journal, holen Läufe nach, die verpasst wurden, während der Server aus war (Persistent=true), und ein Dienst kann warten, bis die Backup-Platte eingehängt ist:
# /etc/systemd/system/backup-paperless.service
[Unit]
Description=Paperless backup
RequiresMountsFor=/backup
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-paperless.sh
# /etc/systemd/system/backup-paperless.timer
[Unit]
Description=Nightly Paperless backup
[Timer]
OnCalendar=*-*-* 00:30
Persistent=true
[Install]
WantedBy=timers.target
Eingeschaltet wird er mit systemctl enable --now backup-paperless.timer. systemctl list-timers zeigt, wann jeder Job zuletzt lief und wann er als Nächstes läuft, und journalctl -u backup-paperless zeigt seine Ausgabe. Meine Jobs starten zwischen 23:30 und 03:00 Uhr, mindestens 15 Minuten auseinander, damit sie sich nie um die Platte streiten.
7. Dienste, die besondere Sorgfalt brauchen
- Nextcloud: der Job schaltet den Wartungsmodus ein, dumpt die Datenbank, kopiert die Dateien und schaltet ihn wieder aus. Sonst könnte jemand zwischen Dump und Dateikopie eine Datei hochladen, und beides passt nicht mehr zusammen. Die Kopie läuft mit einer Bandbreitenbegrenzung von rsync (
--bwlimit), damit die Platten nicht ausgelastet sind, und um 03:00 Uhr, wenn niemand etwas hochlädt. In einer normalen Nacht sind das wenige Minuten Wartungsmodus. - Zigbee2MQTT: sein Ordner enthält den Netzwerkschlüssel und die Datenbank aller gekoppelten Geräte. Ohne ihn muss jedes Gerät neu gekoppelt werden. Der Container wird für die wenigen Sekunden der Kopie pausiert (
docker pause, nicht gestoppt), damit die Datenbankdatei konsistent ist. - Authentik und BookStack: die Datenbank allein reicht nicht. Beide haben in ihrer
.enveinen geheimen Schlüssel, der Sitzungen signiert oder Daten verschlüsselt. Stell den Dump zusammen mit der.envaus derselben Nacht wieder her, sonst funktionieren Logins und verschlüsselte Felder nicht mehr. - Der Reverse Proxy: die
acme.jsonvon Traefik enthält alle Zertifikate und den Let’s-Encrypt-Kontoschlüssel (siehe meinen Traefik-Artikel). Ohne sie fordert Traefik jedes Zertifikat neu an und kann in Ratenlimits laufen. - Seiten beim Webhoster: der Server meldet sich mit einem SSH-Schlüssel an, holt den WordPress-Ordner als tar-Archiv und dumpt die Datenbank auf dem Host. Die Backups des Hosters sind ein Bonus, kein Plan.
Deine Backup-Platte ist voller Geheimnisse: Passwort-Tresor, Datenbank-Passwörter, Signaturschlüssel, der Zigbee-Netzwerkschlüssel. Behandle sie wie den Server selbst: physisch sicher, Ordner nur für root lesbar (chmod 700), und die Kopie, die den Server verlässt, verschlüsselt.
8. Die dritte Kopie: geholt, nur lesend, verschlüsselt
Der naheliegende Weg zu einer zweiten Kopie: der Server schiebt sie irgendwohin. Das Problem: ist der Server gehackt oder von Ransomware befallen, hat der Angreifer denselben Zugriff und kann auch diese Kopie löschen oder verschlüsseln. Deshalb funktioniert meine dritte Kopie andersherum. Ein Laptop holt sich den neuesten Snapshot jedes Jobs auf eine verschlüsselte USB-SSD, und der Server hat keinen Weg dorthin:
| Entscheidung | Warum |
|---|---|
| Holen statt schieben | Der Server hat überhaupt keinen Zugang zum Laptop. Ein kompromittierter Server kann die Kopie nicht anfassen. |
| Nur-Lese-Schlüssel auf dem Server | Ein eigener Benutzer darf nur rrsync -ro auf dem Backup-Ordner ausführen. Der Laptop kann lesen, nie schreiben oder löschen. |
Neuer Snapshot mit Datum pro Lauf, nie --delete | Verschwinden Snapshots auf dem Server (aus Versehen oder absichtlich), bleiben die älteren Kopien auf der SSD erhalten. |
| Gleiche Ordnerstruktur | Das Restore-Skript funktioniert auf der SSD unverändert. |
| LUKS-Verschlüsselung mit Schlüsseldatei | Nur während eines Laufs entsperrt, danach wieder geschlossen. Eine verlorene SSD ist unlesbar. |
Die rrsync-Einschränkung ist eine Zeile in der authorized_keys des Servers:
command="sudo rrsync -ro /backup",restrict ssh-ed25519 AAAA… offsite-pull
Der Job auf dem Laptop läuft stündlich, tut aber nur etwas, wenn es Sinn ergibt: höchstens einmal am Tag, nur am Netzteil, nie während der Server seine eigenen Backups macht (23:15–04:30 Uhr), und nur wenn die SSD angesteckt ist und der Server antwortet. Außerdem prüft er, was er kopiert:
- Stichproben: Dumps nicht leer, gzip-Archive lesbar, die Vaultwarden-Datenbank besteht ihre Integritätsprüfung, die Zertifikatsdatei ist nicht leer.
- Ransomware-Alarm: vor der Nextcloud-Kopie zählt ein Probelauf, wie viele Dateien sich geändert haben. Sind es mehr als 20 %, stimmt etwas gewaltig nicht (so sieht Massenverschlüsselung aus). Die Kopie wird behalten, aber alte Generationen werden nicht mehr gelöscht, bis ich es mir angesehen habe.
- Längerer Verlauf als auf dem Server: die neuesten 7 Kopien, dann eine pro Woche für 8 Wochen und eine pro Monat für 12 Monate. Nextcloud mit etwa 800 GB behält 10 Generationen.
9. Wissen, dass es geklappt hat
Ein Backup, das vor drei Wochen still und leise aufgehört hat, ist der häufigste Weg, Daten zu verlieren. Deshalb sammelt ein kleines Python-Skript auf dem Server alle fünf Minuten den Zustand jedes Jobs und schreibt ihn in eine JSON-Datei: letzter Lauf, Ergebnis, Alter des neuesten Snapshots, ob der Timer aktiv ist, ob die Backup-Platte eingehängt ist.
- Eine kleine Statusseite auf dem Server zeigt das als Tabelle.
- Home Assistant liest dieselbe Datei und legt pro Job einen Sensor an (
ok,failed,stale, …). Hat ein Job zehn Minuten lang ein Problem, bekomme ich eine Push-Benachrichtigung und eine Telegram-Nachricht. „Stale“ heißt, dass der neueste Snapshot älter als 26 Stunden ist; das erwischt auch einen Timer, der einfach nie gelaufen ist. - Abdeckungsprüfung: das Skript listet außerdem jeden laufenden Container auf und vergleicht ihn mit einer Liste abgedeckter Dienste. Ein neuer Container ohne Backup-Job erscheint als Warnung. Damit war die Lücke geschlossen, vor der ich am meisten Angst hatte: an einem Sonntagnachmittag einen Dienst hinzufügen und sein Backup vergessen.
Zur Abdeckungsliste gehört eine Hausregel: jeder neue Container bekommt seinen Backup-Job, seinen Wiederherstellungsschritt und seinen Eintrag in der Abdeckungsliste in derselben Änderung. „Später“ ist der Weg, auf dem Backups vergessen werden.
10. Wiederherstellen
Zuerst der einfache Weg: die meisten „ich habe etwas gelöscht“-Momente lassen sich in der App selbst lösen. Nextcloud hat gelöschte Dateien und Versionen, Paperless einen Papierkorb für 30 Tage. Erst wenn das nicht hilft, gehe ich an die Snapshots, und eine einzelne Datei ist nur eine Kopie aus dem richtigen Datumsordner.
Für den schlimmen Tag, wenn der Server weg ist oder mehrere Dienste kaputt sind, liegt ein interaktives Restore-Skript auf der Backup-Platte selbst:
- Jobs wählen: einen, mehrere oder alle, in fester Reihenfolge: zuerst die Infrastruktur (Reverse Proxy, Zertifikate), dann Single Sign-on, dann die Datenbanken und Apps.
- Snapshot wählen: den neuesten, ein Datum für alle oder pro Job.
- Schritte pro Job wählen: Dateien, Datenbank, Container.
- Probelauf: das Skript zeigt jeden Befehl, den es ausführen würde, und macht erst weiter, wenn ich
RESTOREeintippe.
Es löscht nie etwas: Dateien, die es überschreibt, wandern in einen Ordner .pre-restore-<Zeit>, und jede Datenbank wird gedumpt, bevor sie ersetzt wird. Die Backup-Timer pausieren während der Wiederherstellung, damit kein Backup-Job gegen ein halb wiederhergestelltes System läuft.
Ein Restore-Skript, das du nie ausgeführt hast, ist eine Hoffnung, kein Plan. Meins hat einen Prüfstand: gefälschte docker-, systemctl– und sshd-Befehle und gefälschte Snapshots, damit es jedes Szenario durchspielen kann, ohne echte Daten anzufassen. Das sind 211 Prüfungen, und sie müssen nach jeder Änderung am Restore-Skript bestehen. Eine echte Wiederherstellung auf einer Ersatzmaschine ab und zu ersetzt das trotzdem nicht: das ist der einzige Beweis, der zählt.
Tipp: Halte fest, wo die Wiederherstellungsanleitung liegt, und sorg dafür, dass sie auf der Backup-Platte und auf der Offline-Kopie ist, nicht nur auf dem Server, den du vielleicht gerade wiederherstellst. Bei mir sind das eine README neben den Snapshots, eine Kopie jedes Skripts in seinem Backup-Ordner und das Restore-Skript selbst.
Was ich gelernt habe
- Langweilige Werkzeuge gewinnen. rsync, Bash und systemd gibt es auch in zehn Jahren noch, und ich kann mit nichts als einer Shell wiederherstellen.
- Datenbanken dumpen und den Dump prüfen. Ein kopierter Datenbankordner ist ein Glücksspiel; ein ungeprüfter Dump eine Überraschung, die nur darauf wartet.
- Snapshots sind keine Kopien. Hardlinks geben dir Verlauf, keine Redundanz. Du brauchst trotzdem eine zweite Platte.
- Die Kopie, die einen Angriff überleben muss, holen statt schieben. Was der Server erreichen kann, kann auch ein Angreifer auf dem Server erreichen.
- Die Backups überwachen, nicht nur die Dienste. Der Alarm „seit 26 Stunden kein neuer Snapshot“ ist mehr wert als jede Funktion des Backups selbst.
- Geheimnisse gehören zu den Daten. Schlüssel und
.env-Dateien aus derselben Nacht wie die Datenbank, sonst sieht die Wiederherstellung gut aus und nichts funktioniert.