Mein VPS wird jetzt jede Nacht gesichert – und zwar so, dass er selbst keinen Weg ins Homelab bekommt. Das Homelab holt sich die Daten ab, nicht umgekehrt.

Worum geht’s?

Die Website selbst ist mir eigentlich egal, wenn der VPS mal umfällt. Die baut die Pipeline aus dem Git-Repo in ein paar Sekunden neu. Was mir nicht egal ist: die ganze Handarbeit in /etc. Caddy-Konfiguration mit Security-Headern und CSP-Hashes, SSH-Härtung, ufw, logrotate, die eingeschränkten Benutzer für Deploy und Log-Abruf. Das alles noch mal aus dem Kopf (oder aus meinen Notizen) nachzubauen, wäre ein Abend, auf den ich keine Lust habe.

Also: ein Backup. Aber eins, das zu meinem Grundsatz für diesen Server passt.

Pull statt Push

Die meisten Backup-Setups arbeiten nach dem Push-Prinzip: Der Server, der gesichert wird, verbindet sich zum Backup-Ziel und schiebt seine Daten dorthin. Für einen VPS im Internet heißt das aber: Er bräuchte Zugangsdaten und eine offene Tür in mein Homelab. Wird der VPS kompromittiert, hat der Angreifer diese Tür gleich mit – im schlimmsten Fall inklusive Schreibrechten auf meine Backups.

Beim Pull-Prinzip läuft es andersherum: Der VPS legt das Backup nur lokal ab. Eine VM in meinem Homelab holt es nachts ab – und darf dabei ausschließlich lesen, und das auch nur in genau einem Verzeichnis. Der VPS weiß nicht einmal, dass es mein Homelab gibt.

Genau so habe ich es schon beim Abholen der Zugriffsprotokolle für die Besucherstatistik gemacht. Das Muster hat sich bewährt, also bekommt das Backup denselben Aufbau.

Was gesichert wird – und was nicht

GesichertNicht gesichert
/etc komplett (inkl. SSH-Host-Keys)/var/www – baut die Pipeline neu
authorized_keys aller Benutzer/var/lib/caddy – Zertifikate holt Caddy neu
Paketliste (dpkg --get-selections)/var/log – siehe unten
manuell installierte Pakete (apt-mark showmanual)

Die Logs lasse ich bewusst weg. In meiner Datenschutzerklärung steht, dass Zugriffsprotokolle nach 14 Tagen gelöscht werden. Ein Backup mit 30 Tagen Vorhaltung, in dem die Logs drinstecken, würde diese Aussage still und heimlich aushebeln.

Die SSH-Host-Keys im Archiv sind der Grund, warum das Backup im Homelab verschlüsselt liegen soll. Wer die privaten Host-Keys hat, kann sich gegenüber SSH-Clients als mein Server ausgeben.

Voraussetzungen

  • VPS: Debian 13, rsync mit rrsync (liegt bei Debian im rsync-Paket unter /usr/bin/rrsync), systemd
  • Homelab: eine Debian-13-VM (bei mir die Docker-VM), rsync 3.5.0, BorgBackup 1.4.0 aus den Debian-Paketen
  • Die VM selbst wird täglich per Proxmox Backup Server gesichert – das Borg-Repo wandert also automatisch mit

Ein Wort zu Borg 2: Gibt es, ist aber zum Zeitpunkt dieses Artikels noch Beta und laut den Entwicklern nicht für den Produktivbetrieb gedacht. Ich bleibe bei 1.4.

Umsetzung

Schritt 1: Ein Benutzer, der nur lesen darf

Auf dem VPS bekommt der Abruf einen eigenen Systembenutzer und ein Zielverzeichnis, in das nur root schreiben darf:

sudo useradd --system --user-group --create-home --home-dir /home/backuppull --shell /bin/sh backuppull
sudo install -d -m 0750 -o root -g backuppull /var/backups/vps
sudo install -d -m 0700 -o backuppull -g backuppull /home/backuppull/.ssh

Die Shell muss eine echte sein, kein nologin: sshd startet den erzwungenen Befehl (dazu gleich mehr) über die Login-Shell des Benutzers.

Schritt 2: Pull-Key mit Maulkorb

Auf der Homelab-VM erzeuge ich einen eigenen SSH-Key nur für diesen Zweck. Ohne Passphrase, weil er nachts ohne mich laufen muss:

sudo ssh-keygen -t ed25519 -N '' -C 'homelab vps-backuppull' -f /root/.ssh/vps_backuppull_ed25519

Ein Key ohne Passphrase klingt erst mal nach schlechter Idee. Entscheidend ist, was man mit ihm darf – und das regle ich auf dem VPS in der authorized_keys:

restrict,command="/usr/bin/rrsync -ro /var/backups/vps" ssh-ed25519 AAAA... homelab vps-backuppull
  • restrict schaltet alles ab, was SSH sonst noch kann: Port-Forwarding, Agent-Forwarding, X11, ein Terminal.
  • command= ist ein Forced Command: Egal, was der Client ausführen will, sshd startet stattdessen diesen Befehl.
  • rrsync -ro /var/backups/vps ist ein Wrapper für rsync, der den Zugriff auf genau dieses Verzeichnis begrenzt – und mit -ro nur lesend.

Selbst wenn jemand den Key aus meinem Homelab klaut, kann er damit nur das abholen, was ohnehin für das Homelab bestimmt ist.

Schritt 3: Host-Key nicht blind vertrauen

Damit das Homelab sicher sein kann, dass es wirklich mit meinem VPS spricht, braucht es dessen Host-Key. Der bequeme Weg wäre ssh-keyscan – aber dann vertraue ich dem, was in dem Moment antwortet. Stattdessen lese ich den Key direkt auf dem VPS aus …

cat /etc/ssh/ssh_host_ed25519_key.pub

… und lege ihn auf der Homelab-VM in einer eigenen known_hosts-Datei nur für diesen Abruf ab:

echo 'example.com ssh-ed25519 AAAA...' | sudo tee /root/.ssh/known_hosts_vps >/dev/null
sudo chmod 0600 /root/.ssh/known_hosts_vps

Zusammen mit StrictHostKeyChecking=yes bricht die Verbindung ab, sobald sich am anderen Ende ein anderer Key meldet.

Schritt 4: Testen – auch das, was nicht gehen soll

Bevor irgendein Skript läuft, teste ich beide Richtungen. Lesen muss gehen:

SSH="ssh -i /root/.ssh/vps_backuppull_ed25519 -o IdentitiesOnly=yes -o UserKnownHostsFile=/root/.ssh/known_hosts_vps -o StrictHostKeyChecking=yes"
sudo rsync -e "$SSH" backuppull@example.com:./
drwxr-x---          4,096 2026/10/31 11:29:50 .

Schreiben muss scheitern:

sudo touch /tmp/rrsync-test
sudo rsync -e "$SSH" /tmp/rrsync-test backuppull@example.com:./
/usr/bin/rrsync error: sending to read-only server is not allowed

Genau so soll es sein. Ein Test, der nur prüft, ob etwas funktioniert, ist bei Security-Themen nur die halbe Miete.

Schritt 5: Das Backup-Skript auf dem VPS

Das Skript packt alles in ein unkomprimiertes tar. Warum unkomprimiert, kommt gleich bei Borg. Wichtig ist der Trick mit der .part-Datei: Das Archiv bekommt erst seinen richtigen Namen, wenn es fertig ist. So kann der Abruf nie ein halb geschriebenes Archiv erwischen.

#!/bin/bash
# /usr/local/sbin/vps-backup.sh
set -euo pipefail
umask 027

DEST=/var/backups/vps
KEEP_DAYS=3
NAME="vps-$(date +%Y-%m-%d_%H%M).tar"
PART="$DEST/.${NAME}.part"

TMP=$(mktemp -d)
trap 'rm -rf "$TMP" "$PART"' EXIT

dpkg --get-selections > "$TMP/dpkg-selections.txt"
apt-mark showmanual    > "$TMP/apt-manual.txt"

FILES=(etc)
for u in thomas deploy logpull backuppull; do
    h=$(getent passwd "$u" | cut -d: -f6) || continue
    if [ -n "$h" ] && [ -f "$h/.ssh/authorized_keys" ]; then
        FILES+=("${h#/}/.ssh/authorized_keys")
    fi
done

tar --create --file "$PART" -C / "${FILES[@]}" -C "$TMP" dpkg-selections.txt apt-manual.txt
chgrp backuppull "$PART"
chmod 0640 "$PART"
mv "$PART" "$DEST/$NAME"

find "$DEST" -maxdepth 1 -name 'vps-*.tar' -mtime +$((KEEP_DAYS - 1)) -delete

Die Home-Verzeichnisse hole ich per getent statt sie fest einzutragen. Ein Archiv ist bei mir rund 2,7 MB groß, auf dem VPS liegen immer die letzten drei Tage.

Schritt 6: systemd-Timer mit Härtung

Statt Cron nutze ich einen systemd-Timer – und gebe dem Dienst gleich ein paar Fesseln mit:

# /etc/systemd/system/vps-backup.service
[Unit]
Description=VPS-Konfigurationsbackup nach /var/backups/vps

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/vps-backup.sh
Nice=10
IOSchedulingClass=idle
ProtectSystem=strict
ReadWritePaths=/var/backups/vps
ProtectHome=read-only
PrivateTmp=yes
NoNewPrivileges=yes
# /etc/systemd/system/vps-backup.timer
[Unit]
Description=Täglicher VPS-Konfigurationsbackup um 02:30

[Timer]
OnCalendar=*-*-* 02:30:00 Europe/Berlin
RandomizedDelaySec=5min
Persistent=true

[Install]
WantedBy=timers.target
  • ProtectSystem=strict macht das komplette Dateisystem für den Dienst schreibgeschützt – bis auf das, was in ReadWritePaths steht.
  • ProtectHome=read-only reicht, weil das Skript die authorized_keys nur lesen muss.
  • Die Zeitzone direkt in OnCalendar sorgt dafür, dass der Job sicher vor dem Abruf um 03:00 läuft – auch wenn der Server mal auf UTC stehen sollte.
sudo systemd-analyze verify /etc/systemd/system/vps-backup.service /etc/systemd/system/vps-backup.timer
sudo systemctl daemon-reload
sudo systemctl enable --now vps-backup.timer
sudo systemctl start vps-backup.service

Der Testlauf über systemd ist wichtig: Erst da merkt man, ob die Härtung dem Skript etwas wegnimmt, das es braucht.

Schritt 7: Borg im Homelab

BorgBackup ist ein Backup-Programm, das Daten in kleine Blöcke zerlegt, doppelte Blöcke nur einmal speichert (Deduplizierung), komprimiert und verschlüsselt. Ich nutze es nur auf der Homelab-Seite.

Die Passphrase liegt für die nächtlichen Läufe in einer Datei, die nur root lesen kann. Ganz wichtig: Sie wird nur erzeugt, wenn es die Datei noch nicht gibt. Überschreibt man sie nach dem Anlegen des Repos versehentlich, ist das Backup verschlüsselt – und zwar für immer.

sudo install -d -m 0700 /root/.config/borg
sudo sh -c 'umask 077; f=/root/.config/borg/vps.passphrase; [ -s "$f" ] || head -c 32 /dev/urandom | base64 > "$f"'

sudo -H env BORG_PASSCOMMAND="cat /root/.config/borg/vps.passphrase" \
  borg init --encryption=repokey-blake2 /opt/vps-backup/borg

Bei repokey liegt der (verschlüsselte) Schlüssel im Repository selbst, die Passphrase entsperrt ihn. Passphrase und einen Key-Export habe ich zusätzlich in meinen Passwortmanager gelegt:

sudo -H borg key export /opt/vps-backup/borg

Den brauche ich nur, wenn Homelab-VM und Proxmox Backup Server gleichzeitig weg sind. Aber genau für diesen Fall macht man Backups.

Schritt 8: Abholen und einlagern

Das Skript auf der Homelab-VM holt die Archive per rsync ab, legt jedes neue tar als eigenes Borg-Archiv ab und räumt danach auf:

#!/bin/bash
# /usr/local/sbin/vps-backup-pull.sh
set -euo pipefail
umask 077
export HOME=/root

BASE=/opt/vps-backup
INCOMING="$BASE/incoming"
DONE_LIST="$BASE/archived.list"
export BORG_REPO="$BASE/borg"
export BORG_PASSCOMMAND="cat /root/.config/borg/vps.passphrase"
SSH="ssh -i /root/.ssh/vps_backuppull_ed25519 -o IdentitiesOnly=yes -o UserKnownHostsFile=/root/.ssh/known_hosts_vps -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=30"

exec 9>"$BASE/.lock"
flock -n 9 || { echo "Läuft bereits, Abbruch." >&2; exit 1; }
touch "$DONE_LIST"

# 1. Abholen: incoming spiegelt die letzten 3 Tage vom VPS
rsync -rt --delete --exclude='.*' -e "$SSH" backuppull@example.com:./ "$INCOMING/"

# 2. Neue Tar-Dateien in Borg übernehmen
cd "$INCOMING"
shopt -s nullglob
for f in vps-*.tar; do
    name="${f%.tar}"
    grep -qxF "$name" "$DONE_LIST" && continue
    borg create --stats --compression zstd,6 --timestamp "$f" "::$name" "$f"
    echo "$name" >> "$DONE_LIST"
done

# 3. Vorhaltung: 30 Tage
borg prune --list --keep-daily 30 --glob-archives 'vps-*'
borg compact

# 4. Plausibilität: jüngstes Tar darf nicht älter als 26 Stunden sein
if ! find "$INCOMING" -maxdepth 1 -name 'vps-*.tar' -mmin -1560 | grep -q .; then
    echo "FEHLER: kein VPS-Backup jünger als 26 Stunden" >&2
    exit 1
fi

Ein paar Details, die mir wichtig waren:

  • --exclude='.*' sorgt dafür, dass .part-Reste nie im Homelab landen.
  • --timestamp "$f" setzt als Archivzeit die Erstellungszeit auf dem VPS, nicht die Uhrzeit des Abrufs.
  • Die archived.list merkt sich, was schon in Borg liegt. Ohne sie würde prune ein Archiv löschen, das am nächsten Tag noch im Spiegel liegt – und das Skript würde es fröhlich wieder anlegen.
  • export HOME=/root steht drin, damit Borg unter systemd denselben Cache findet wie bei meinen Aufrufen von Hand.
  • Der Plausibilitätscheck am Ende lässt den Dienst fehlschlagen, wenn der VPS nichts Neues geliefert hat. Daran hänge ich später eine Benachrichtigung.

Dazu kommen ein Service und ein Timer nach demselben Muster wie auf dem VPS – diesmal um 03:00 und mit ReadWritePaths=/opt/vps-backup /root/.cache/borg /root/.config/borg, weil Borg in seinen Cache und sein Security-Verzeichnis schreiben muss.

Schritt 9: Der Restore-Test

Ein Backup, das man nie zurückgespielt hat, ist eine Hoffnung, kein Backup. Also hole ich das tar aus Borg zurück – ohne es auf die Platte zu schreiben – und vergleiche die Prüfsumme mit dem Original:

sudo -H env BORG_PASSCOMMAND="cat /root/.config/borg/vps.passphrase" \
  borg extract --stdout /opt/vps-backup/borg::vps-2026-10-31_1134 vps-2026-10-31_1134.tar | sha256sum
sudo sha256sum /opt/vps-backup/incoming/vps-2026-10-31_1134.tar

Beide Werte identisch. Dazu noch einmal alles entschlüsseln und prüfen lassen:

sudo -H env BORG_PASSCOMMAND="cat /root/.config/borg/vps.passphrase" \
  borg check --verify-data /opt/vps-backup/borg && echo "CHECK OK"
CHECK OK

Stolperfallen & Learnings

Borg auf dem VPS? Lieber nicht. Meine erste Idee war, Borg direkt auf dem VPS laufen zu lassen. Borg ist aber auf Push ausgelegt: Der Client verbindet sich zum Repository. Damit wäre ich wieder bei der Tür ins Homelab. Es gibt zwar einen dokumentierten Pull-Modus über einen SSH-Reverse-Tunnel, aber für ein paar Megabyte /etc war mir das zu viel Bastelei. Die Aufteilung „tar auf dem VPS, Borg im Homelab" ist einfacher und behält das Pull-Prinzip.

Die Deduplizierung greift kaum. Ich hatte erwartet, dass das zweite Archiv am selben Tag praktisch nichts kostet – der Inhalt war ja derselbe. Stattdessen: wieder rund 450 kB. Der Grund: Borg zerlegt Daten standardmäßig in Blöcke von etwa 2 MiB. Ein 2,7-MB-tar besteht damit aus gerade mal vier Blöcken, und ein einziges geändertes Byte (etwa ein Zeitstempel im tar-Header) macht einen ganzen Block neu. Bei 30 Tagen sind das rund 13 MB – kein Drama. Wer es sauberer will, kann mit --chunker-params kleinere Blöcke einstellen.

prune hat nicht gelöscht, was ich erwartet hatte. Zwei Archive am selben Tag, --keep-daily 30 – ich dachte, das ältere fliegt raus. Borg 1.4 behält aber zusätzlich das älteste Archiv (daily[oldest]), solange es weniger Tage gibt, als man behalten will. Ist so gewollt und erledigt sich nach 30 Tagen von selbst.

os-release ist leer? Beim Restore-Test wollte ich mir etc/os-release aus dem wiederhergestellten tar anzeigen lassen – und bekam gar nichts. Kein Fehler im Backup: Unter Debian ist /etc/os-release nur ein Symlink, und im tar steckt deshalb auch nur der Link. Mit etc/debian_version kam dann brav 13.7 zurück.

Logs sehen nur Auserwählte. journalctl -u … zeigte als normaler Benutzer schlicht „No entries". Ohne Mitgliedschaft in der Gruppe systemd-journal (oder sudo) sieht man die System-Logs nicht. Klingt banal, hat mich aber kurz irritiert.

Fazit

Der VPS sichert sich jetzt jede Nacht selbst, das Homelab holt das Ergebnis ab und legt es verschlüsselt in Borg – und über den Proxmox Backup Server landet das Ganze noch ein zweites Mal woanders. Der VPS hat dabei keinen einzigen Zugang ins Homelab, und der Abruf-Key kann nichts außer lesen. Das fühlt sich deutlich besser an als „irgendwann mach ich mal ein Backup".

Was als Nächstes kommt

  • Benachrichtigung bei Fehlern: Beide Dienste beenden sich mit einem Fehlercode, wenn etwas schiefgeht. Das soll mir künftig jemand sagen, statt dass ich es zufällig bemerke.
  • Der richtige Restore-Test: Eine Prüfsumme ist gut, ein frischer Server ist besser. Ich will das Archiv auf einer Test-VM auspacken, die Pakete aus der Paketliste nachinstallieren und schauen, ob danach wirklich alles wieder so dasteht wie vorher.