Skip to content

Backup & restore

When to use: scheduled backups (daily cron recommended), before upgrades (automatic), migrating to a new host.

Writes DIR/<ISO timestamp>/:

  • database.dump — pg_dump -Fc of the whole database (domains, mailboxes, aliases, users, audit log, server config history, setup state, profile pictures, and every message waiting to be sent later or held unsent, whole and with its attachments; one sent or cancelled is deleted, so it is in no backup taken on a release after 0.14.0).
  • vmail.tar.gz — the mailserver_vmail volume (all Maildirs, sieve scripts). The fts-flatcurve full-text indexes are excluded: they are derived from the mail itself and would add a large fraction of its size to every backup. The mail queue is excluded too — the mailqueue volume holds mail that has been accepted but not yet delivered, and it survives an upgrade but not a lost volume or a down -v. Check it is shallow before a restore drill: Mail queue (viewer, hold / release / delete).
  • env — the .env file (secrets).
  • manifest.json — version, hostname, SHA-256 + size of each part.

Every part is private: the timestamped directory is mode 0700 and each file in it 0600, whatever the operator’s umask. The dump holds every password hash and sealed secret and every message still waiting to be sent later, the tarball every mailbox’s mail, and env the key that opens the sealed secrets. Backups written by mailctl 0.11.0 and earlier left database.dump and vmail.tar.gz world-readable (0644) under a usual umask: chmod -R go= <backup root> closes them. Keep the same in whatever you ship them to.

Backups are consistent enough for a running server (Maildir is append-mostly; pg_dump is transactional). Default location /opt/mailserver/backups. Rotate and ship them yourself (e.g. rclone); the product does not.

Cron example: 0 3 * * * /usr/bin/mailctl backup --out /var/backups/mailserver >> /var/log/mailctl-backup.log 2>&1

Restore — mailctl restore --from DIR/<timestamp> [--yes]

Section titled “Restore — mailctl restore --from DIR/<timestamp> [--yes]”

Preconditions: mailctl installed on the target host (npm i -g @maildock/mailctl — not the bootstrap script, which always goes on to run mailctl install); Docker running.

  1. Checksums in manifest.json are verified first; a mismatch aborts.
  2. Confirmation prompt (skip with --yes) — restore replaces the database and all mail.
  3. env → /opt/mailserver/.env, with MACHINE_FINGERPRINT rewritten to this host’s (the backup’s names the machine it was taken on; a restore onto new hardware then needs a licence transfer); compose bundle materialised; postfix, dovecot, api and cert-sync stopped — four services, not three. cert-sync is in the list because it watches the certs volume and drops reload renders for Postfix and Dovecot; left running it would push a render at daemons that are mid-restore. A backup whose env has no DB_ADMIN_PASSWORD, or an empty one, gets DB_ADMIN_PASSWORD=mail written and a warning printed. That is the password such a database was created with, and the bundle mailctl now writes supplies none, so without it the API would refuse to start. Rotate it afterwards (Upgrade → One-time: an .env without its secrets). An empty or absent DB_DAEMON_PASSWORD, DOVECOT_MASTER_PASSWORD or SRS_SECRET gets a fresh value, listed in the output.
  4. Database dropped and re-created, pg_restore applied; vmail volume emptied and untarred (ownership 5000:5000).
  5. Stack started; waits for API health. The API re-applies migrations if the restored dump is older than the running images.
  6. Messages scheduled to send later come back held. The backup cannot say which of them left after it was taken, so after pg_restore and before the API starts, mailctl restore marks every one that was waiting as Not sent, with the reason restored from a backup; none is sent by the server again. Each stays in its mailbox’s Scheduled list for the owner to send, move or cancel. Sent is no guide: the restore put the mailbox back as it was at the backup too, so a message that left since is missing there. The Postfix log is not part of a backup and survives the restore: look a held message up by its Message-ID (SELECT message_id, envelope->>'subject' FROM scheduled_message WHERE error LIKE 'held: restored%', then docker exec mailserver-postfix-1 grep '<message-id>' /var/log/mail/postfix.log; a status=sent line means it went), and tell the owners which ones did.
  7. Search works immediately but unindexed (and so slowly) until the full-text index is rebuilt — new mail is indexed as it arrives, and docker exec mailserver-dovecot-1 doveadm index -A '*' rebuilds the history in one pass. It reads every message, so run it out of hours on a large server. See Webmail search and list performance.

mailctl doctor ✔; log in with an existing admin; IMAP shows the expected mail; GET /api/v1/server/config shows the expected hostname.

Take a backup before restoring if the current state matters; restoring that backup returns to it.

SECRET_ENCRYPTION_KEY is part of the backup, not an afterthought

Section titled “SECRET_ENCRYPTION_KEY is part of the backup, not an afterthought”

.env carries SECRET_ENCRYPTION_KEY, and a database restored without it is not a working restore. Every value sealed with SecretBox becomes unreadable at once — the AI provider key, every TOTP seed, every DKIM private key, the outbound relay password and the installed licence key together. Both the api and the worker hold the key: the worker opens the licence key for its heartbeat and seals the refreshed one it gets back.

What that looks like afterwards, so it is recognised rather than debugged:

symptom recovery
AI provider key the AI settings page reports the key as unreadable re-enter the key
admin console 2FA sign-in refused with totp_unreadable another owner or admin clears it on the Users screen (Clear two-factor authentication beside the user, shown only when the seed is unreadable), then the user signs in with their password and enrols again (the clear also deletes their recovery codes, which match nothing under the new key). A sole owner clears their own on the host: docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env exec api node dist/server.js clear-broken-totp <email> (with the install directory in both paths, as the rollback in Install writes it: the compose file is under compose/, where a bare docker compose does not look). It refuses a seed the running key can open, and is audited as user.totp.clear_broken (host). A session that is still valid can also disable 2FA from the Account page
mailbox 2FA webmail sign-in refused with totp_unreadable; IMAP and SMTP keep working on app passwords an administrator clears it on the mailbox’s page (which also deletes its recovery codes — keyed by the same secret, they match nothing now), then the owner re-enrols and gets a new set
DKIM keys a dkim_unreadable alert per domain: warning while rspamd still signs with the key file it already had, critical where it had none and the domain sends unsigned (a new host) restore the old .env; or rotate each named domain’s DKIM key and publish the new record (DKIM signing)
relay password Settings → Outbound relay shows the relay password as unreadable; Postfix keeps relaying with the credentials it already has enter the relay password again in Settings → Outbound relay
licence key the Licence page reads unlicensed, saying the installed key cannot be read; the heartbeat fails, and mailctl upgrade logs in to the registry without the key restore the old .env; or the owner activates the licence key again (Licence, or mailctl license activate)

None of these stops mail, and none is meant to lock anybody out permanently. That is deliberate, and the admin-console row meets it too, a sole owner included — but it is still an incident, and restoring .env alongside the database avoids all of it.