Stack backups: scheduled volume archives to SFTP/local storage (ADR-0016) - #34
Merged
Merged
Conversation
…DR-0016)
Archives each opted-in stack's compose volumes daily through a never-started
helper container + the Docker archive endpoint, optionally stopping the
stack's containers for the snapshot only (per-stack toggle, default on).
Archives are gzipped, optionally encrypted in the OpenSSL enc container
format (restore needs only stock openssl), and shipped to a pluggable
IBackupStorage: built-in SFTP (SSH.NET) or a local directory. Retention
(days + max count) prunes per stack folder, never the newest backup, and
only Watchtower-named files.
Runs serialize through a single-flight queue with per-stack coalescing and
are recorded as BackupEvent rows (swept to failed on restart, like deploys).
Global knobs live under Watchtower:Backup:* in the runtime settings store
(env vars pin, ADR-0014): backups.getConfig/updateConfig/testStorage plus
backups.run/events/getStackConfig/setStackConfig, surfaced as a Settings
card and a per-stack Backups tab. Any volume is also downloadable as a
.tar.gz from the UI via GET /api/volumes/{name}/download (same helper
mechanism, session-protected).
Docs: docs/backups.md (setup + restore procedure), ADR-0016.
The exact inverse of a backup run: download the chosen archive from storage (decrypting with the configured passphrase), scan its table of contents, stop the stack's containers, wipe and refill only the volumes present in BOTH the archive and on the host (mismatches are logged, never guessed at), restart. Extraction goes through the same helper-container mechanism — the daemon's archive endpoint unpacks straight into the mounts, preserving ownership and modes; the volume wipe is the one step that executes code in the helper, so the image must carry a shell (busybox does). backups.listRemote lists what is actually on the storage (the local history is not the truth: retention deletes files behind old events); backups.restore validates the name against the backup pattern, requires the passphrase for .enc archives, refuses during deploys, and enqueues on the same single-flight queue (restores never coalesce and are refused while the stack has anything queued or running). The UI flow is pick-from-storage + typed-name confirm. IBackupStorage gains DownloadAsync and sizes on listing. Docs: UI restore is now the primary path; the manual openssl route stays as disaster recovery.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the stack backup system designed in ADR-0016: daily archives of each opted-in stack's named volumes, shipped to external storage with retention and optional encryption, plus on-demand volume downloads from the UI.
How it works
busybox:stable). Abackup-manifest.json(instance, stack, volumes, timestamp) is injected so every archive self-describes.IBackupStoragewith two built-ins — SFTP (SSH.NET; password and/or private key; Hetzner Storage Box friendly, port 23 documented) and local directory. Layout:{base}/{instance}/{stack}/{project}_{yyyyMMddTHHmmssZ}.tar.gz[.enc]. Uploads write a.partialname and rename on completion.enccontainer format (PBKDF2-SHA256, 600k iterations) — restore needs nothing but stock openssl; an interop test decrypts a real openssl-CLI ciphertext.Backup:Time) + per-stack opt-in; single-flight queue with per-stack coalescing;BackupEventhistory rows swept tofailedon restart, like deploys.Watchtower:Backup:*via the settings store with env-var pinning (ADR-0014); secrets are write-only toward the UI. New RPC surface:backups.getConfig/updateConfig/testStorage/events/run/getStackConfig/setStackConfig.GET /api/volumes/{name}/downloadstreams any named volume as.tar.gz(same helper mechanism), session-protected like the SSE streams.UI
Docs
docs/backups.md: setup (incl. Storage Box specifics and SSH key guidance), encryption notes, and the step-by-step restore procedure; linked from the README.docs/decisions/0016-stack-backups.mdrecords the trade-offs (file-level snapshots, CBC-without-MAC, manual restore in v1).Verification
tscand the production frontend build are clean;rpc-schema.jsonregenerated (101 methods).AddStackBackups(backfillsbackup_stop_containers = truefor existing stacks).Restore (second commit)
backups.listRemote— the local history is not the truth, retention deletes behind it), then a typed-name confirm runs the inverse of a backup: download + decrypt, scan the tar's table of contents, stop the stack, wipe and refill only volumes present in both the archive and on the host (mismatches logged, never guessed), restart. Lands in the same history as triggerrestore..encarchives require the configured passphrase up front. The volume wipe is the only step that executes code in the helper container (needssh/rm— busybox has them).IBackupStoragegainsDownloadAsync+ sizes on listing; 4 more tests (archive table-of-contents scan, storage download).