feat(ops): backup and restore the whole installation - #88
Conversation
dockercmd --backup <file> writes a portable snapshot (database + projects/ + project-templates/); --restore puts it back. The database snapshot goes through a live connection with VACUUM INTO, so a backup is safe while the server is running. Copying the .db file is not: it runs in WAL mode, so committed data can still sit in the -wal file and a copy taken mid-write is torn. Both secret keys are rows inside that database, which is what makes a backup self-contained — it restores onto a fresh machine as-is. It also means the archive is equivalent to the plaintext of every stored secret, since the key travels next to the ciphertext. So the file is written 0600 and --passphrase encrypts it with AES-256-GCM under an Argon2id-derived key, with the format magic as AAD. The passphrase is read from the terminal with echo off, or from stdin when piped — otherwise scheduled backups could not be encrypted at all, which is the case that matters most for a server. It is never an argument, where it would land in shell history and /proc/<pid>/cmdline. Restore refuses to overwrite an existing installation without --force, so a mistyped path cannot destroy a running instance, and every archive entry is jailed to the data dir: a tampered backup cannot write elsewhere, including through a symlink whose target escapes. 12 tests, 5 of them pentests (tar slip, escaping symlink, wrong passphrase, tampered ciphertext, encrypted-without-passphrase). Also verified end to end against a real instance: backed up while running, restored to a fresh data dir, logged in with the original password, and confirmed the restored server could decrypt the stored SMTP secret. golang.org/x/term moves from an indirect to a direct dependency; no new module enters the graph.
| if err != nil { | ||
| return err | ||
| } | ||
| defer f.Close() |
| return err | ||
| } | ||
| if _, err := io.Copy(out, tr); err != nil { | ||
| out.Close() |
| if err != nil { | ||
| return err | ||
| } | ||
| defer out.Close() |
There was a problem hiding this comment.
🟡 Not ready to approve
There is a compile-breaking allocation in encrypted restore length handling and several security/operational hardening gaps (bounds-checking encrypted lengths, forced restore target sanitization, and ensuring archive perms on overwrite).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds an ops-focused, CLI-only mechanism to create/restore a full installation snapshot (SQLite DB + projects/ + project-templates/), optionally encrypting the archive with a user-supplied passphrase and documenting the security implications.
Changes:
- Add
Store.BackupTo()usingVACUUM INTOfor WAL-safe live DB snapshots. - Introduce
internal/backupto create/restore archives, including AES-GCM + Argon2id encryption and tar-slip protections. - Wire
dockercmd --backup/--restore [--passphrase] [--force]and update docs/changelog.
File summaries
| File | Description |
|---|---|
| internal/store/store.go | Adds BackupTo helper for consistent DB snapshots via VACUUM INTO. |
| internal/backup/backup.go | Implements backup archive creation, optional encryption, and restore/extraction logic. |
| internal/backup/backup_test.go | Adds unit tests + pentests for restore hardening (tar slip, symlink escape, tamper/wrong passphrase, etc.). |
| cmd/dockercmd/main.go | Adds CLI parsing and execution for --backup / --restore, including passphrase reading from TTY/stdin. |
| docs/deployment.md | Documents backup/restore usage, safety constraints, and security warning. |
| CHANGELOG.md | Notes the new backup & restore feature and its security posture. |
| go.mod | Adds direct dependency on golang.org/x/term. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 8
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
| var n [8]byte | ||
| if _, err := io.ReadFull(r, n[:]); err != nil { | ||
| return nil, ErrNotABackup | ||
| } | ||
| sealed := make([]byte, binary.BigEndian.Uint64(n[:])) | ||
| if _, err := io.ReadFull(r, sealed); err != nil { | ||
| return nil, ErrNotABackup | ||
| } |
| func checkTarget(dataDir string, force bool) error { | ||
| if force { | ||
| return os.MkdirAll(dataDir, 0o700) | ||
| } | ||
| if _, err := os.Stat(filepath.Join(dataDir, dbFileName)); err == nil { | ||
| return fmt.Errorf("backup: %s already contains an installation — stop the server and pass --force to overwrite it", dataDir) | ||
| } | ||
| return os.MkdirAll(dataDir, 0o700) | ||
| } |
| if _, err := os.Stat(path); err == nil { | ||
| // VACUUM INTO refuses to overwrite, and a stale file would fail the backup | ||
| // for a confusing reason. | ||
| return fmt.Errorf("store: %s already exists", path) | ||
| } |
| err := Restore(archive, dataDir, "", true) | ||
| if err == nil && name != "/etc/pwned.txt" { | ||
| t.Errorf("SECURITY: entry %q was accepted", name) | ||
| } |
| ```bash | ||
| dockercmd --backup /var/backups/dc-$(date +%F).tar.gz # plain | ||
| dockercmd --backup /var/backups/dc.tar.gz --passphrase # encrypted (prompts) | ||
| echo "$PASS" | dockercmd --backup /var/backups/dc.tar.gz --passphrase # for cron | ||
| ``` | ||
|
|
| // Snapshot through a live connection so the WAL is accounted for. This is | ||
| // safe with the server running. | ||
| st, err := store.Open(filepath.Join(dataDir, "docker-commander.db")) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer st.Close() | ||
| if err := backup.Create(dataDir, file, storeBackuper{st}, passphrase); err != nil { |
| } | ||
| } | ||
|
|
||
| // 2. Build the tar.gz payload in memory-free streaming fashion to a temp file. |
| // 3. Emit, encrypting if asked. 0600 either way: even encrypted, this file is | ||
| // the whole installation. | ||
| f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| defer f.Close() | ||
| if _, err := f.Write(magic); err != nil { | ||
| return err | ||
| } |
Summary
You were right that this was missing — there was no way to snapshot or rebuild an
installation, and the docs only said "back up
DC_DATA_DIR".A backup contains the database plus
projects/andproject-templates/— which iseverything, because both secret keys are rows inside that database. So an
archive restores onto a fresh machine and simply works: original password, roles,
projects, and decryptable secrets.
Type of change
Checklist
go test -short ./...andgo vet ./...passgofmtgate is clean (gofmt -l $(git ls-files '*.go')after staging)web/dist— N/A (nothing underweb/srcchanged)docs/and added aCHANGELOG.mdentry for user-facing changesNotes for reviewers
Two findings drove the design; both are worth checking.
1.
VACUUM INTO, not a file copy. The database runs in WAL mode, socopying
docker-commander.dbcan miss committed data still in the-walfile, orcatch a write mid-flight and yield a torn database. The snapshot goes through a
live connection instead, which makes
--backupsafe while the server isrunning — the normal case for a scheduled job.
2. The archive is equivalent to your secrets in plaintext. The at-rest
encryption key is a row in the database being backed up, sitting next to every
ciphertext it protects — host TLS keys, SMTP and LDAP passwords, registry
credentials. Hence
0600always, a loud warning when unencrypted, and--passphrase(AES-256-GCM, Argon2id at deliberately heavier parameters than thelogin hash, since a backup is attacked offline at leisure). The format magic is
used as AAD so the header can't be swapped.
The passphrase is read from the terminal, or from stdin when piped. I hit this
during testing:
term.ReadPasswordfails on a non-TTY, which would have meantscheduled backups could never be encrypted — the case that matters most for the
OPS scenario. It's never an argument, so it stays out of shell history and
/proc/<pid>/cmdline.12 tests, 5 pentests. The ones to read: tar slip (
../pwned,projects/../../pwned, absolute paths) and an escaping symlink whose targetclimbs out of the data dir — restore runs as the service account, and a backup may
have travelled. Plus wrong passphrase, tampered ciphertext, and
encrypted-without-passphrase, all failing closed with nothing written.
Verified end to end against a real instance, not just unit tests: set an SMTP
password, a custom role and a project; backed up while the server was running;
restored into a fresh data dir; started it; logged in with the original password;
confirmed the role and project were there; and confirmed the restored server could
decrypt the stored SMTP secret (the test reached a DNS lookup rather than
failing at decryption). Then repeated the whole thing encrypted, checked the
plaintext really is absent from the archive, and that
--force-less restore over alive install is refused.
Deliberately CLI-only. A UI button would mean an HTTP endpoint that streams
every secret to a browser session — I'd rather not add that surface without you
asking for it.
--restorealso has to replace the database wholesale, which meansthe server must be stopped anyway.
Dependency:
golang.org/x/termmoves from indirect to direct. No new moduleenters the graph.
Not covered
Only
projects/andproject-templates/are archived. Anything else under thedata dir — e.g.
tls/from--make-certs— is left out deliberately: it isreproducible and machine-specific. Say the word if you'd rather it travelled too.