Deep Dive

compose-backup: Docker-Stacks sichern, verschlüsseln, versionieren

Wie ein kleines eigenes Werkzeug jeden Docker-Stack umzugsfertig einpackt, verschlüsselt nach S3 schickt und dort aus einem bloßen Spiegel eine Historie von 30 Tagen macht.

30. Oktober 2026 · 9 Min. Lesezeit

dockeransiblehetznerntfypaperlessngx

compose-backup: sichern, verschlüsseln, versionierenEin Compose-Stack wird kurz angehalten, seine Daten wandern in ein Archiv, das ein Prüfsummen-Siegel und ein Schloss bekommt. Das verschlüsselte Archiv fliegt in einen S3-Bucket. Dort stapeln sich die Versionen: Jede Nacht kommt oben eine neue dazu, die älteste verblasst nach 30 Tagen. Aus dem Spiegel wird eine Historie.Compose-StackläuftgestopptArchiv + SHA-256anhalten · packen · prüfen · startenrclone cryptverschlüsselt nach S3neu30 TageverfälltS3: aus dem Spiegel wird eine HistorieS3-Historie

Wer schon einmal umgezogen ist, kennt den Moment. Der Karton ist zu, das Klebeband drauf, und irgendwo im Hinterkopf fragt eine Stimme, ob die Schrauben vom Regal jetzt in diesem Karton sind oder noch in der Schublade. Im September stand ich genau da, nur dass die Wohnung ein Rechner war. Paperless sollte vom alten Arbeitsplatz auf eine eigene VM ziehen: sechs Container, eine Postgres-Datenbank, 11 GB Dokumente.

Ordner kopieren und fertig, so einfach ist es bei Docker leider nicht. Die Daten liegen in Volumes, die Docker irgendwo selbst verwaltet. Die Datenbankdateien gehören einem Benutzer, den es auf dem neuen Rechner gar nicht gibt. Und latest zeigt in drei Monaten auf ein anderes Image als heute. Was muss also in den Karton, damit ein Stack auf der anderen Seite wirklich wieder läuft? Und wie wird aus einem einmaligen Umzug eine Sicherung, die jede Nacht von selbst passiert?

Meine Antwort darauf ist ein kleines Werkzeug, das ich mir dafür geschrieben habe: compose-backup, eine einzelne Python-Datei, die nur die Standardbibliothek und das Docker-Kommando braucht.

Was in den Karton gehört

Ein Compose-Stack ist eine Gruppe von Containern, die eine compose.yaml beschreibt. Die Container selbst sind Wegwerfware, Docker baut sie jederzeit neu aus ihren Images. Wertvoll ist, was drumherum liegt.

Da sind zuerst die Daten, und die liegen an zwei Orten. Volumes sind Speicherbereiche, die Docker selbst verwaltet, bei Paperless etwa die Datenbank. Bind Mounts sind gewöhnliche Ordner oder Dateien des Hosts, die in einen Container eingeblendet werden. Dazu kommt die .env des Stacks, in der die Passwörter im Klartext stehen. Ohne sie startet nichts.

Ein Lauf von compose-backup legt das alles in einen Ordner, der sich selbst beschreibt. So sieht er aus:

paperless-<stand>/
├── compose.yaml          Original
├── compose.pinned.yaml   Images per Digest fest
├── .env                  Secrets (chmod 600)
├── manifest.json         Volumes, Pfade, Prüfsummen
├── SHA256SUMS            für sha256sum -c
├── volumes/<name>.tar.gz
└── binds/<pfad>.tar.gz

Die Datei mit dem seltsamen Namen compose.pinned.yaml ist mir die liebste. image: postgres:18 sagt nämlich nur, welches Etikett auf dem Image klebte. Ein Tag wie 18 oder latest ist ein Zeiger, den der Hersteller jederzeit weiterschiebt. Der Digest dagegen ist die Prüfsumme genau eines Images. Die gepinnte Fassung schreibt beides hin (Digest hier gekürzt):

image: docker.io/postgres:18@sha256:7157393f508f…

Steht im Original latest, setzt das Werkzeug das Versionslabel ein, das viele Images mitbringen, damit man die Zeile auch in einem Jahr noch lesen kann. Variablen wie ${DB_PASSWORD} bleiben Variablen. In der gepinnten Datei landet also kein Passwort.

Bleibt die Frage mit dem fremden Benutzer. Postgres läuft als UID 999, und wer dessen Dateien vom Host aus kopiert, verliert Besitzer und Rechte oder scheitert gleich ganz. Deshalb packt bei mir ein winziger Alpine-Container, der das Volume nur lesend eingehängt bekommt. Besitzer, Rechte und Zeitstempel kommen so unversehrt an.

Anhalten, packen, versiegeln

Jetzt wissen wir, was in den Karton gehört. Beim Packen selbst gibt es eine Falle: Eine Datenbank, die während des Packens weiterschreibt, landet womöglich in einem Zustand im Archiv, den es so nie gab. Deshalb hält --stop den Stack für die Dauer kurz an. Wer die Option vergisst und eine Datenbank im Stack hat, bekommt eine Warnung.

Sichern: Stack anhalten, Images pinnen, Volumes und Bind Mounts per Hilfscontainer packen, SHA-256 je Archiv berechnen, Stack wieder starten; das Starten passiert auch, wenn das Packen scheitert. Zurückholen: alle Prüfsummen prüfen, bei Abweichung abbrechen; alle Volumes auf Inhalt prüfen und ohne --force abbrechen; erst dann Dateien und Volumes schreiben und mit --pinned --up die gepinnten Images starten.
Links das Einpacken, rechts das Auspacken. Beide prüfen erst und schreiben danach.

Links im Bild steht der Ablauf beim Sichern. Er ist jedes Mal gleich: anhalten, Images pinnen, packen, für jedes Archiv eine SHA-256-Prüfsumme schreiben, wieder starten. Das Starten steckt in einem finally-Block und passiert deshalb auch, wenn beim Packen etwas schiefgeht. Ein Backup, das den Dienst kaputt zurücklässt, wäre ein schlechter Tausch.

Für Paperless sieht der Aufruf so aus:

compose-backup backup -f /opt/paperless/compose.yaml --stop \
  --exclude consume --exclude export

Der Eingangsordner ist eine NFS-Freigabe und gehört nicht hierher, und den Export baut Paperless jede Nacht ohnehin neu.

Auspacken, aber vorsichtig

Die rechte Hälfte des Bildes ist mir wichtiger als die linke. Ein Restore überschreibt Daten, und ein Restore läuft selten an einem entspannten Nachmittag.

Bevor restore irgendetwas anfasst, rechnet es die Prüfsumme jedes Archivs nach und vergleicht sie mit dem Manifest. Fehlt eine Datei oder stimmt eine Summe nicht, ist Schluss, und kein einziges Byte ist geschrieben. Danach schaut es nach, ob die Ziel-Volumes leer sind. Hat eines schon Inhalt, verlangt es ein ausdrückliches --force, und zwar nachdem es alle Volumes geprüft hat. Einen halb zurückgeholten Stack gibt es so nicht.

Erst dann wird geschrieben. Mit --pinned kommt die gepinnte Compose-Datei zurück, mit --up startet der Stack, und zwar mit genau den Images vom Tag der Sicherung:

compose-backup verify  /srv/backups/paperless/paperless-<stand>
compose-backup restore /srv/backups/paperless/paperless-<stand> \
  --force --pinned --up

verify allein prüft nur und schreibt nie. Und ich verlasse mich nicht auf mein Wort: Die Tests sichern einen echten Stack, löschen ihn, holen ihn zurück und vergleichen Inhalt und Besitzer, und sie stellen sicher, dass ein manipuliertes Archiv oder ein volles Volume abgelehnt wird.

Jede Nacht um halb vier

So weit ein einzelner Stack. In meinem Homelab läuft aber jeder Dienst in einer eigenen VM, und keine davon soll ich von Hand sichern müssen. Also zoomen wir ein Stück heraus.

Die Ansible-Rolle compose_backup richtet auf jeder Docker-VM dasselbe ein: rclone, das Skript als /usr/local/bin/compose-backup, einen kleinen Wrapper namens backup-host.sh, eine Konfiguration mit den Zugangsdaten, die nur root lesen darf, und einen systemd-Timer. Der feuert um 03:30 Uhr, mit bis zu 20 Minuten Zufall obendrauf, damit nicht alle VMs gleichzeitig an der Leitung ziehen.

Der Wrapper geht jeden Stack unter /opt/*/compose.yaml durch, sichert ihn mit --stop und behält lokal unter /srv/backups die letzten drei Stände. Ist eine Platte über 85 % voll, schreibt er mir das per ntfy aufs Handy.

Dann verlässt das Backup die VM. rclone sync spiegelt den Ordner in einen S3-Bucket bei Hetzner, und zwar durch ein crypt-Remote, das Inhalte und Dateinamen verschlüsselt, bevor sie die VM verlassen. Das ist Pflicht, denn in jedem Stand steckt eine .env. Den Schlüssel habe ich zusätzlich im Passwortmanager, denn ohne ihn ist jedes einzelne Backup im Bucket nur teurer Zufall.

Aus dem Spiegel wird eine Historie

Ein Spiegel klingt erst einmal gut. Er hat aber eine unangenehme Eigenschaft: Er ist treu. Lokal liegen drei Stände, also im Bucket auch nur drei. Sichert eine VM eines Nachts Unsinn, lädt rclone sync den Unsinn brav hoch und räumt dafür den ältesten guten Stand ab. Nach drei schlechten Nächten wäre nichts Gutes mehr übrig.

Die VM behält drei lokale Stände und spiegelt sie mit rclone sync durch crypt in den Bucket. Ohne Versionierung liegen dort ebenfalls nur drei Stände, ein gelöschter ist weg. Mit Versionierung wird jedes Überschreiben und Löschen zu einer alten Version; die Lifecycle-Regel entfernt alte Versionen nach 30 Tagen.
Links der treue Spiegel, rechts derselbe Spiegel mit Gedächtnis.

Seit dem 5. September ist deshalb im Bucket die Versionierung an. Wenn rclone jetzt etwas überschreibt oder löscht, entsteht nur eine neue Version, und die alte bleibt abrufbar. Einen Schalter dafür gab es in der Hetzner-Konsole damals nicht, also habe ich sie über die S3-API eingeschaltet, mit rclone backend versioning.

Damit der Bucket nicht endlos wächst, sorgt eine Lifecycle-Regel für Ordnung. Das ist eine Aufräumregel, die der Speicher selbst ausführt, und bei mir lässt sie alte Versionen nach 30 Tagen verfallen. Unterm Strich habe ich drei aktuelle Stände plus alles, was in den letzten 30 Tagen überschrieben wurde. Einen Stand von einem bestimmten Zeitpunkt holt rclone gleich entschlüsselt zurück:

rclone copy --s3-version-at "2026-09-05T18:00:00" \
  crypt:paperless/<stand> /restore/

Ehrlicherweise gehört eine Grenze dazu. Versionierung schützt mich vor meinem eigenen Spiegel und vor Versehen. Wer aber mit denselben Zugangsdaten gezielt alte Versionen löscht, den hält sie nicht auf.

Sonntags um fünf

Die zweite Gelegenheit, bei der compose-backup arbeitet, ist das wöchentliche Update. Eine weitere Ansible-Rolle, compose_update, zieht sonntags um 05:00 neue Images. Gibt es für einen Stack keine, passiert gar nichts. Gibt es welche, sichert das Skript den Stack zuerst lokal, startet ihn mit den neuen Images, wartet 30 Sekunden und schaut, ob alle Container laufen und gesund sind.

Dann kommt eine Nachricht aufs Handy, und darin steht genau der Befehl, der den Stand von vor dem Update zurückholt. Ausgeführt wird er nie automatisch. Ein Rollback dreht auch Daten zurück, die seit dem Update dazugekommen sind, und das will ich selbst entscheiden. Ist ein Stack nach dem Update krank, steht derselbe Befehl als „Rollback empfohlen“ in einer Meldung mit hoher Priorität.

Aus der Praxis: An einem Sonntagmorgen Ende September lasen sich die Update-Meldungen, als wäre gerade auf acht VMs alles zurückgedreht worden. Jede endete mit „Rollback: … compose-backup restore … –force –pinned –up“. Die Journale zeigten, dass nichts davon passiert war; der Befehl war nur der vorbereitete Rückweg. Seitdem ist die Meldung mehrzeilig, nennt zuerst, was aktualisiert wurde, und darüber steht ausdrücklich, dass der Befehl NICHT ausgeführt wurde. Um fünf Uhr morgens liest niemand eine Nachricht so, wie sie gemeint war.

Und der Umzug?

Zurück zum Karton vom Anfang. Der Umzug im September war im Grunde ein einziger großer Restore. Paperless habe ich auf dem alten Rechner mit --stop gesichert, 11 GB in fünf Minuten. Dann kam der Ordner per rsync auf die neue VM, und dort hat restore erst alle Prüfsummen nachgerechnet und dann ausgepackt. Danach standen 767 Dokumente in der Datenbank, und jeder Dienst, der später auf demselben Weg umzog, war eine weitere Generalprobe.

Die Fehler dabei gingen auf mein Konto. Zweimal hatte ich einen Schrägstrich zu viel am Ende eines rsync-Pfads, und der Inhalt landete eine Ebene zu hoch. Und ein Restore unter sudo hinterließ Dateien, die root gehörten.

Ganz ohne Kratzer kam das Werkzeug selbst aber auch nicht davon.

Aus der Praxis: Mitte September habe ich 50 GB Fotos in Immich importiert. In der Nacht danach packte das Backup brav die komplette Fotobibliothek ein, als 56-GB-Archiv auf die 120-GB-Datenplatte. Am Sonntagmorgen wollte das Update vorher noch eine Sicherung anlegen, die Platte lief voll, Postgres stürzte ab, und Immich antwortete nur noch mit 502. In derselben Nacht scheiterte das Backup von Authentik, weil das Werkzeug den eingebundenen Docker-Socket für einen Ordner hielt; immerhin fiel dort deshalb das Update aus, und der alte Stand lief einfach weiter. Seitdem überspringt compose-backup Sockets, abgesichert mit einem Test, und Immich sichert per --exclude nur noch seine Datenbank, rund 250 MB. Die Fotos sichert die nächtliche VM-Sicherung, die für große Datenmengen ohnehin besser gebaut ist.

Was ich mitnehme

Die Frage vom Anfang war, was in den Karton muss. Heute würde ich sagen: mehr, als man denkt, und dazu ein Zettel, was absichtlich draußen bleibt. Volumes allein reichen nicht. Erst mit Bind Mounts, .env, der exakten Image-Version und Prüfsummen wird aus einem Archiv etwas, das man in drei Monaten wirklich wieder zum Leben erweckt.

Am ruhigsten schlafe ich aber wegen der Vorsicht beim Auspacken. Ein Werkzeug, das erst prüft und ohne --force nichts überschreibt, macht aus einem schlechten Tag zumindest keinen schlimmeren. Und die Versionierung hat aus einem treuen, aber vergesslichen Spiegel ein Gedächtnis von 30 Tagen gemacht.

compose-backup ist dabei nur eine von drei Sicherungsebenen. Wie es mit der Sicherung ganzer VMs und dem Meldeweg zusammenspielt, und wer mich eigentlich weckt, wenn nachts etwas schiefgeht, steht in Teil 6: Sicherung und Überwachung.