backup: no full backup holds the Postfix queue

Owner, 2026-10-11: the queue goes out of the command's archive too.

The commit that made the panel's backup work left the queue in the
archive of selfpost-backup, which runs as root and can read it. But that
copy was never the queue: the archive is made while the instance runs,
Postfix moves messages between the queue's directories all the time, and
a message on its way could be in the archive twice or not at all. One
rule now - QueueDir is left out of every archive - and
Params.Unprivileged is only about a certs/ file the panel may not open.

What is not carried over is mail still waiting for delivery; a restored
instance builds an empty queue (the e2e restore scenario starts one).
To keep the queue: stop the instance and tar the project directory, as
the guide's methods 3 and 4 already describe.

Guide: the description of a full backup, methods 1 and 2 and the
comparison table. Help follows the design commit before this one.

  go vet ./internal/backup/: ok
  go test ./internal/backup/ ./cmd/... ./internal/web/...: ok

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-11 20:06:41 +03:00
co-authored by Claude Opus 5.5
parent 82f212ab39
commit 21e961cb29
5 changed files with 52 additions and 50 deletions
+21 -23
View File
@@ -1,16 +1,16 @@
// Package backup implements SelfPost's full-server backup and the restore
// version guard (architecture.md § Persistence). A full backup is a
// gzip-compressed tar of the consolidated persistent state under /data — the
// SQLite database (as a consistent snapshot), the per-domain DKIM keys, the
// SASL database, and the Postfix queue — plus docker-compose.yml, .env, and
// certs/ from the operator's deploy directory, and a manifest recording the
// SelfPost version that produced it. Postfix delivery logs under log/ are
// excluded (diagnostics, not state).
// SQLite database (as a consistent snapshot), the per-domain DKIM keys and the
// SASL database — plus docker-compose.yml, .env, and certs/ from the
// operator's deploy directory, and a manifest recording the SelfPost version
// that produced it. Postfix delivery logs under log/ are excluded
// (diagnostics, not state), and so is the Postfix queue (see QueueDir).
//
// Restore is not a separate code path in the panel: a backup is extracted into
// the operator's project directory (data/, docker-compose.yml, .env, certs/)
// before first start. The archive carries everything needed to bring the
// instance back — DKIM keys, sasldb2, sender map, queue, and deploy files —
// instance back — DKIM keys, sasldb2, sender map, and deploy files —
// so the operator only adjusts hostname or proxy settings on a new host. The
// restore-specific steps the panel runs are CheckRestore, which refuses to
// boot if the manifest's version does not match the running binary so
@@ -81,22 +81,23 @@ type Params struct {
DeployRoot string
OnWarn func(string)
// Unprivileged says the archive is made by the panel, which runs as its
// own user and not as root. Two things under the roots are then not its to
// read, and are left out instead of failing the whole archive:
//
// - the Postfix queue (QueueDir). Postfix keeps every queue file and
// directory readable by its own user alone, and that cannot be
// configured. What is lost is mail not yet delivered at that moment;
// a start without a queue directory builds an empty one.
// - a file under certs/ the panel may not open, which is usually the
// private key (root and Postfix's group only). OnWarn names it.
//
// The selfpost-backup command, run as root, leaves this unset and takes
// both.
// own user and not as root. A file under certs/ it may not open - usually
// the private key, root and Postfix's group only - is then left out
// instead of failing the whole archive, and OnWarn names it. The
// selfpost-backup command, run as root, leaves this unset and takes it.
Unprivileged bool
}
// QueueDir is the Postfix queue, relative to the data directory.
// QueueDir is the Postfix queue, relative to the data directory. No backup
// holds it (owner, 2026-10-11). An archive is made while the instance runs,
// and Postfix moves messages between the queue's directories all the time: a
// copy taken then can hold a message twice or not at all, so what it would
// bring back is not the queue. Nor could the panel read it - Postfix keeps
// every queue file readable by its own user alone, which is why the panel's
// backup failed outright from 1.3.0 to 2.0.0. What is not carried over is mail
// still waiting for delivery; a start without a queue directory builds an
// empty one. Whoever needs the queue stops the instance and archives the
// project directory (guide § Taking a full backup).
const QueueDir = "postfix/queue"
// excludedFromArchive lists the data-directory entries a backup never carries.
@@ -203,11 +204,8 @@ func validateDeployRoot(deployRoot string) error {
// left out of the archive. A directory left out is not descended into.
type skipFunc func(path, rel string, d fs.DirEntry) bool
// skipData leaves the Postfix queue out of an unprivileged archive.
// skipData leaves the Postfix queue out of every archive.
func (p Params) skipData() skipFunc {
if !p.Unprivileged {
return nil
}
return func(_, rel string, _ fs.DirEntry) bool {
return filepath.ToSlash(rel) == QueueDir
}
+3 -3
View File
@@ -112,7 +112,6 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
DataArchivePrefix + "sasl/sasldb2",
DataArchivePrefix + "postfix/sender_login_maps",
DataArchivePrefix + "postfix/queue/deferred/sample",
ComposeFileName,
EnvFileName,
CertsDirName + "/fullchain.pem",
@@ -129,6 +128,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
DataArchivePrefix + "selfpost.db-shm",
DataArchivePrefix + "log/mail.log",
DataArchivePrefix + "log/mail.log.1",
DataArchivePrefix + "postfix/queue/deferred/sample",
} {
if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name)
@@ -236,8 +236,8 @@ func TestCheckRestoreWrongFormatRejected(t *testing.T) {
}
}
// The panel's archive: no Postfix queue, everything else as before, and Check
// agrees that it can be made.
// The panel's archive: like any other it has no Postfix queue, the rest is
// there, and Check agrees that it can be made.
func TestCreateUnprivilegedLeavesTheQueueOut(t *testing.T) {
dataDir, dbPath, deployRoot := seedDataDir(t)
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.2.3", DeployRoot: deployRoot, Unprivileged: true}
+2 -2
View File
@@ -190,8 +190,8 @@ var serverTopics = []HelpTopic{
{ID: HelpBackup, Title: "Backup and moving a domain", Body: []Text{
Rich("Full backup downloads one archive of the whole instance: ", Code("data/"), " (the database, DKIM keys and application credentials), ",
Code("docker-compose.yml"), ", ", Code(".env"), " and ", Code("certs/"),
". The panel cannot read the Postfix queue, nor usually the private key in ", Code("certs/"),
", and leaves them out; ", Code("selfpost-backup"), " run in the container takes both. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as ", Code(".spbk"),
". The Postfix queue is not in it: mail still waiting for delivery is not carried over. Nor, usually, is the private key in ", Code("certs/"),
", which the panel cannot read; ", Code("selfpost-backup"), " run in the container takes the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as ", Code(".spbk"),
", the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a ",
Code(".spbk"), " first with ", Code("selfpost-backup -decrypt"),
") and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and ",