Coldfeet holds the only copy of every quarantined and archived message, plus the
key that decrypts DKIM private keys, relay passwords and TOTP secrets. Losing the
volume loses mail; losing ENCRYPTION_KEY makes a restored database unreadable.
install/backup.sh writes one directory per run under backup_data/:
| File | Contents |
|---|---|
database.sql.gz[.enc] | pg_dump of the whole database |
eml.tar.gz[.enc] | the coldfeet_eml_storage volume: quarantine, archive, branding |
vmail.tar.gz[.enc] | the coldfeet_vmail volume: hosted mailboxes, when this install has any |
mail-crypt.tar.gz[.enc] | the keypair the Maildir is encrypted with, only when the backup is encrypted |
env.production[.enc] | secrets, only when the backup is encrypted |
manifest.json | file list with sizes and SHA-256 checksums |
Quarantine and archive copies expire; a hosted mailbox is the customer's only
copy, so on a mailserver or hybrid install vmail.tar.gz is the artifact
that actually matters. It is unreadable without mail-crypt.tar.gz, which is
why an unencrypted backup warns rather than writing that keypair out in clear.
backup_data/latest.json always describes the most recent run, including
whether it succeeded and whether it was encrypted.
Set BACKUP_PASSPHRASE in .env.production (the installer generates one) and
every artifact is encrypted with AES-256 before it touches the disk. Without it
the script still runs, but it warns and refuses to include .env.production.
The passphrase lives in .env.production, which is itself inside the encrypted
backup. Keep a copy off this host — the installer writes one to
data/super-admin-credentials.txt, which you should move somewhere safe and then
delete.
The installer adds /etc/cron.d/coldfeet-backup, which runs nightly at 02:30 and
logs to backup_data/backup.log. Pass --skip-backup-cron to opt out.
Retention prunes runs older than BACKUP_RETENTION_DAYS (default 14) but never
goes below BACKUP_KEEP_MIN copies (default 3), so a host that was offline for a
month does not wake up and delete its own history.
cd /opt/coldfeet
sudo ./install/backup.sh # labelled "manual"
sudo ./install/backup.sh --retention-days 30 # override retention for this run
cat backup_data/latest.json
# Everything, replacing what is there now
sudo BACKUP_PASSPHRASE=... ./install/restore.sh backup_data/cron-20260728-023000 --clean
# Just the database, merging into the existing schema
sudo BACKUP_PASSPHRASE=... ./install/restore.sh backup_data/cron-20260728-023000 --db-only
restore.sh verifies the manifest checksums, stops api/worker/web before
touching Postgres, and prompts before overwriting. --clean drops and recreates
the database, which is what you want when restoring to a known-good point rather
than merging.
Mailboxes are restored with the message store (--db-only skips both). Dovecot
is stopped first so nothing is writing into the Maildir as it is replaced. A
mail-crypt keypair already on the host is kept rather than overwritten — if
restored mail reads as noise, that host has the wrong key and the one in the
backup is the one you want.
Secrets are never overwritten automatically. On a rebuilt host, recover them explicitly:
sudo BACKUP_PASSPHRASE=... ./install/restore-env.sh backup_data/cron-20260728-023000
docker compose -f docker-compose.prod.yml up -d --force-recreate
Run this quarterly. An untested backup is a guess.
Take a fresh backup and note its size.
sudo ./install/backup.sh --label drill
cat backup_data/latest.json
Expect "status": "success" and "encrypted": true.
Record the current state so you can prove the restore did something:
docker compose -f docker-compose.prod.yml exec -T postgres \
psql -U postgres -d securemail -c \
"SELECT (SELECT count(*) FROM email_logs) AS logs, (SELECT count(*) FROM quarantine_items) AS quarantined;"
Restore onto a scratch host (preferred) or a scratch database on this one:
POSTGRES_DB=securemail_drill sudo -E ./install/restore.sh backup_data/drill-... --db-only --clean --yes
Verify the row counts match what you recorded in step 2.
Verify the secrets round-trip. On the scratch host, restore the environment and confirm the API starts and can read a DKIM key:
sudo -E ./install/restore-env.sh backup_data/drill-...
docker compose -f docker-compose.prod.yml up -d
curl -sf http://127.0.0.1/api/health
A failure here means ENCRYPTION_KEY did not survive, which is the failure
mode that silently breaks signing and relay delivery after a real recovery.
Send a test message through the restored gateway and confirm it is scanned and delivered.
Tear the scratch host down and record the drill date.
.env.production for restore-env.sh,
since that is the file being replaced.manifest.json does
not match. Use an older run; do not bypass the check.ENCRYPTION_KEY. Restore the matching .env.production.