Backup & restore
When to use: scheduled backups (daily cron recommended), before upgrades (automatic), migrating to a new host.
Backup — mailctl backup [--out DIR]
Section titled “Backup — mailctl backup [--out DIR]”Writes DIR/<ISO timestamp>/:
database.dump—pg_dump -Fcof 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 after0.14.0).vmail.tar.gz— themailserver_vmailvolume (all Maildirs, sieve scripts). Thefts-flatcurvefull-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 — themailqueuevolume holds mail that has been accepted but not yet delivered, and it survives an upgrade but not a lost volume or adown -v. Check it is shallow before a restore drill: Mail queue (viewer, hold / release / delete).env— the.envfile (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.
- Checksums in
manifest.jsonare verified first; a mismatch aborts. - Confirmation prompt (skip with
--yes) — restore replaces the database and all mail. env→/opt/mailserver/.env, withMACHINE_FINGERPRINTrewritten 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 andcert-syncstopped — four services, not three.cert-syncis in the list because it watches thecertsvolume and drops reload renders for Postfix and Dovecot; left running it would push a render at daemons that are mid-restore. A backup whoseenvhas noDB_ADMIN_PASSWORD, or an empty one, getsDB_ADMIN_PASSWORD=mailwritten and a warning printed. That is the password such a database was created with, and the bundlemailctlnow writes supplies none, so without it the API would refuse to start. Rotate it afterwards (Upgrade → One-time: an.envwithout its secrets). An empty or absentDB_DAEMON_PASSWORD,DOVECOT_MASTER_PASSWORDorSRS_SECRETgets a fresh value, listed in the output.- Database dropped and re-created,
pg_restoreapplied;vmailvolume emptied and untarred (ownership 5000:5000). - Stack started; waits for API health. The API re-applies migrations if the restored dump is older than the running images.
- Messages scheduled to send later come back held. The backup cannot say which of them left after it was taken, so after
pg_restoreand before the API starts,mailctl restoremarks 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%', thendocker exec mailserver-postfix-1 grep '<message-id>' /var/log/mail/postfix.log; astatus=sentline means it went), and tell the owners which ones did. - 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.
Verification
Section titled “Verification”mailctl doctor ✔; log in with an existing admin; IMAP shows the expected mail; GET /api/v1/server/config shows the expected hostname.
Rollback
Section titled “Rollback”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.