Skip to content
BurnerByte

Backup and Restore

What to back up, in what order, and the commands to restore it — including the attachment volume that is easy to miss and the ordering rule that keeps a restore consistent.

Three stores hold state, and only two of them matter. This page is the procedure for both, and the reason the order matters.

It is also the page Docker Compose points at when it says rolling back a release means restoring the dump rather than reversing the schema. Migrations are forward-only in practice, so a restore is your rollback: if you have never tested one, you do not have one.

What Holds State

StoreVolumeBack up?
PostgreSQLpgdataYes. Every inbox, email row, user, domain, audit entry and setting.
Object storageminiodataYes. Attachment bodies. The database only holds their storage_key.
Attachment fallbackattachmentsOnly if it has ever been used. api and smtpd write here when MinIO is unreachable at boot.
RedisredisdataNo. Cache and pub/sub only; the reconciler rebuilds it from PostgreSQL.
The fallback volume is easy to miss

The Ordering Rule

A PostgreSQL dump and an object-store copy are not snapshot-coherent. Between the two, a new email can arrive, get a row, and have its body written to the object store.

Back up the object store first, then the database. That way every storage_key in the dump refers to an object the backup already has. The reverse order produces rows that point at objects that were never captured — a mail that exists in the list and 404s when opened.

For a fully consistent pair, stop the two writers for the duration:

bash
docker compose stop api smtpd# ... take both backups ...docker compose start api smtpd

Mail is not lost while smtpd is down: a sending server that cannot connect retries, which is what the 4xx class of SMTP response exists for.

Backing Up

  1. Copy the object store

    mc mirror from a throwaway client container, into a local directory:

    bash
    docker run --rm \  --network "$(docker compose ps --format '{{.Project}}' api | head -1)_default" \  -v "$PWD/backup/objects:/out" \  --entrypoint sh quay.io/minio/mc -c '    mc alias set src http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" &&    mc mirror --overwrite src/burnerbyte /out  '

    Simpler, if you do not mind stopping MinIO: copy the volume wholesale.

    bash
    docker compose stop miniodocker run --rm -v burnerbyte_miniodata:/data -v "$PWD/backup:/out" \  alpine tar czf /out/miniodata.tar.gz -C /data .docker compose start minio

    Replace burnerbyte_miniodata with your own project prefix — docker volume ls shows it.

  2. Dump the database

    bash
    docker compose exec -T postgres \  pg_dump -U burnerbyte -d burnerbyte --format=custom --no-owner \  > backup/burnerbyte-$(date +%Y%m%d-%H%M).dump

    --format=custom rather than plain SQL: it compresses, and pg_restore can read it selectively. --no-owner so the restore does not insist on a role name that may not exist on the target.

    Substitute your own values if you changed POSTGRES_USER or POSTGRES_DB; the defaults are both burnerbyte.

    A dump that produced no error is not yet a backup you have. Check it lists:

    bash
    pg_restore --list backup/burnerbyte-*.dump | head
  3. Keep the secrets with it

    ENCRYPTION_KEY encrypts every stored credential — SSO client secrets, outbound SMTP passwords, storage keys — at rest, in the database. A dump restored without the same key leaves those rows undecryptable, and no amount of database is going to bring them back.

    Back up .env alongside the dump, or record JWT_SECRET and ENCRYPTION_KEY wherever you keep secrets. Not in the same place as the dump, if that place is less protected than the database was.

Restoring

This destroys the current database
  1. Stop everything that writes

    bash
    docker compose stop api smtpd frontend

    Leave postgres and minio up — you are restoring into them.

  2. Restore the objects first

    Same direction as the backup, reversed:

    bash
    docker compose stop miniodocker run --rm -v burnerbyte_miniodata:/data -v "$PWD/backup:/in" \  alpine sh -c 'rm -rf /data/* && tar xzf /in/miniodata.tar.gz -C /data'docker compose start minio

    Objects before rows, for the same reason as before: a row whose object is missing is a broken email, a spare object nobody references is harmless.

  3. Restore the database

    bash
    docker compose exec -T postgres \  pg_restore -U burnerbyte -d burnerbyte --clean --if-exists --no-owner \  < backup/burnerbyte-20260918-1430.dump

    --if-exists suppresses the errors from dropping objects a fresh database does not have yet. Some notices are normal; a failed pg_restore exits non-zero, so trust the exit code rather than the noise.

  4. Bring it back and verify

    bash
    docker compose start api smtpd frontenddocker compose logs --tail=20 api

    Then check the three things a restore actually gets wrong, in this order:

    1. Sign in. If this fails, JWT_SECRET does not match the one the sessions were signed with — expected, if you restored onto a different stack. Sessions are the one thing a restore is allowed to lose.
    2. Open an email with an attachment and download it. This is the only check that proves the database and the object store agree.
    3. Open Settings → SSO, or outbound SMTP. If the credentials are blank or garbled, ENCRYPTION_KEY does not match.

Schedule and Retention

Nothing here is automated for you. A cron entry on the host running the dump command, plus whatever moves it off the machine, is the whole of it — and a backup that has never left the host it is backing up is not a backup.

Retention is a policy question the product does not answer: disposable inboxes expire on their own, but a dump taken today preserves mail that was meant to be deleted tomorrow. If "no opaque retention" is why you self-host this, keep backups no longer than you need them, and say so where your users can read it.

Note