← All documentation

Backup and restore

Backup and restore

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.

What gets backed up

install/backup.sh writes one directory per run under backup_data/:

FileContents
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.jsonfile 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.

Encryption

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.

Schedule

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.

Manual backup

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

Restore

# 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

Restore drill

Run this quarterly. An untested backup is a guess.

  1. 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.

  2. 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;"
    
  3. 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
    
  4. Verify the row counts match what you recorded in step 2.

  5. 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.

  6. Send a test message through the restored gateway and confirm it is scanned and delivered.

  7. Tear the scratch host down and record the drill date.

Troubleshooting