plMail

Backup and restore

A plMail install is three things, and a backup that has two of them restores nothing useful. This page says what the three are, what app:backup does about them, and how to bring an install up on a different machine.

The three things

What Where it lives Why losing it hurts
The database the database_data volume Every message, thread, label, filter, calendar, user and app password
The encryption key APP_ENCRYPTION_KEY in var/secrets/generated.env, on the app_secrets volume Without it every mailbox password and OAuth token in the database is permanently unreadable
The blobs attachments/, raw/ and uploads/ under APP_STORAGE_DIR Attachment paths are stored in the database relative to the project root, so without the files every attachment 404s after a restore

The secrets file holds more than the key — APP_SECRET, POSTGRES_PASSWORD, MERCURE_JWT_SECRET, the VAPID keypair and the APP_PUBLIC_URL saved during setup — and the JWT keypair sits beside it in var/secrets/jwt/. All of those are regenerable; the encryption key is not.

The configuration — the environment values, those secrets-volume files and the credentials an admin typed into a form — can also be carried on its own, without any mail, as a single encrypted file: see Configuration backup. That is the file you want when you are rebuilding an install rather than recovering one.

Two volumes need no backup at all: caddy_data/caddy_config and mercure_data/mercure_config hold TLS material and hub state that are recreated on their own.

The failure mode is backing the database up and nothing else. It restores every mailbox with credentials that nothing can decrypt, and every attachment link pointing at a file that is not there. Neither announces itself until somebody tries to sync or open something.

app:backup

One command for all three:

docker compose exec php php bin/console app:backup /path/inside/the/container

With no argument it writes to var/backups/<Y-m-d_His>/ inside the project directory. The destination is created 0700, and it contains:

database.sql   pg_dump of everything, --no-owner --no-privileges, mode 0600
attachments/   copied with cp -a
raw/
uploads/       includes avatars, under uploads/avatars/
secrets.env    a copy of var/secrets/generated.env, mode 0600

Two options: --skip-secrets when the key is already backed up elsewhere, and --skip-storage for a database-only snapshot.

pg_dump is run against the parsed DATABASE_URL, so a connection the app can use is a connection the dump can use, and the password is passed through PGPASSWORD in the environment rather than on the command line where ps would show it to every user on the host. The image ships postgresql-client-18 from PGDG on purpose: pg_dump refuses outright to dump a server newer than itself, and the Debian package trails.

The command finishes by saying one of two things, and both are worth reading:

cp -a rather than a flattened copy is deliberate: the storage paths in the database point into the directory bucketing, so a flattened copy restores files nothing can find.

The failure mode is the php container not being able to see the blobs. app:backup copies APP_STORAGE_DIR as that container sees it, and on the stock compose.yaml the blob directories are not on any shared volume — so the web container's copy is not the one the ingest worker wrote into. Fix the mounts before trusting the backup; see the storage section of the Docker page.

Nothing schedules this

app:backup is deliberately absent from MaintenanceSchedule. A backup that runs itself onto the same disk as the thing it is backing up is a false sense of security, and where else it should go is a decision only you can make. Drive it from cron on the host, or from whatever your NAS already uses:

docker compose exec -T php php bin/console app:backup /app/var/backups/nightly

Then move database.sql and secrets.env to different destinations. That is the whole point of encryption at rest.

The failure mode is a backup nobody has ever restored. The first restore is not the moment to discover that the blob directories were empty.

Restoring onto a new host

The order matters, because the secrets have to be in place before anything writes to the database.

1. Put the compose file on the new host and do not start the stack. If it has already been started, take it down and remove its volumes — an install that has generated its own APP_ENCRYPTION_KEY will refuse to boot against a restored database anyway.

2. Put secrets.env back as generated.env. Find the volume name (docker volume ls; it is your project name plus _app_secrets) and write the file into it:

docker run --rm -v pl_mail_app_secrets:/secrets -v "$PWD":/backup:ro alpine \
  sh -c 'cp /backup/secrets.env /secrets/generated.env && chmod 600 /secrets/generated.env'

The bare postgres_password file that the Postgres image reads does not need restoring: the generator rewrites it from the POSTGRES_PASSWORD= line in generated.env every time it runs. The JWT keypair does not need restoring either — app:secrets:init regenerates it when it is missing, at the cost of invalidating any JMAP JWT already issued. App passwords are database rows and are unaffected.

3. Bring up only the database and wait for it.

docker compose up -d database

secrets-init runs first, finds every value already present in the file you restored, and generates nothing.

4. Load the dump.

docker compose exec -T database psql -U app -d app < database.sql

5. Put the blobs back into whatever holds them on this host — the named volumes if you added them, or the bind-mounted directory if you followed the truenas.compose.yaml pattern. What matters is that the paths under APP_STORAGE_DIR match what they were, because the database stores them relative to the project root.

6. Start everything.

docker compose up -d

Migrations run on boot, so a restore onto a newer image brings the schema forward as part of starting. A restore onto an older image does not go backwards — see Upgrading.

7. Check three things. /healthz should answer 200 with database: true; the admin header should show the build you expect; and one mail account should sync. That last one is the real test, because it is the first thing that decrypts a stored credential.

The failure mode is a container that refuses to start with "APP_ENCRYPTION_KEY cannot decrypt the credentials already stored in this database". That message means the restore worked and the key did not come with it. Nothing has been changed — the probe refuses rather than overwriting data the right key could still read. Putting the original key back is the only way to recover those credentials.

Changing the address afterwards

An install restored onto a new host is usually reached at a new address. APP_PUBLIC_URL lives in the restored generated.env, so it still holds the old one. Set it in the environment, or edit that file, then restart the stack — and re-check the redirect URIs registered with Google and Microsoft, which are matched exactly.

The failure mode is push that never resumes. The channels registered by the old install point at the old address; app:calendar:push re-registers hourly once the address is right, and app:push:renew --repair runs nightly for mail.

Things that bite

Copying the database_data volume is not a backup. A running Postgres cluster copied file by file is not consistent. pg_dump — which is what app:backup runs — is.

Storing database.sql and secrets.env together undoes encryption at rest. The command says so loudly, once, at the end of a run nobody reads twice.

An install that supplies APP_ENCRYPTION_KEY from the environment gets a backup with no key in it. That is correct behaviour and it is the most dangerous case in this whole page, which is why the command checks for the key's presence in the copied file rather than assuming it.

POSTGRES_PASSWORD cannot be rotated by restoring a different secrets file. Postgres was initialised with the old one and keeps its own copy, so the app is locked out of a cluster it can otherwise see. Either restore the matching password or start from an empty database volume.

Backups age out of usefulness with the image. A dump taken from an install running a much older tag restores fine and then migrates forward on first boot; a dump from a newer install does not migrate backwards. Note the version chip in the admin header alongside the backup.