English
Backups
A Mankomail instance keeps its state in two places, PostgreSQL and the blob storage, and depends on one secret, ENCRYPTION_KEY. A useful backup covers all three. The source code ships two scripts, scripts/backup.sh and scripts/restore.sh, that work with the reference Docker Compose file and with every blob storage the application supports: MinIO, any S3-compatible service (OVH Object Storage, AWS S3, Garage…), or a local directory (STORAGE_DRIVER=fs).
What a backup must contain
| Where it lives | In the backup made by backup.sh | |
|---|---|---|
| State: members, mailboxes, workflows, runs, the job queue, tables, sessions, encrypted credentials | PostgreSQL | Yes: database.dump |
| Email bodies, attachments, files added to runs | Blob storage: an S3 bucket, or a directory with STORAGE_DRIVER=fs | Yes: blobs/ |
ENCRYPTION_KEY | Your .env file | No, never |
Restoring the first two without the third gives an instance that starts and shows every email, but where no stored credential can be read: every mailbox has to be reconnected, and every AI provider key and connection entered again.
Keep, outside the server and apart from the backups:
ENCRYPTION_KEY, in a password manager or a vault;- the rest of the
.envfile: database and storage passwords,PUBLIC_BASE_URL; - the client ID and secret of your Google and Microsoft OAuth applications. The secret is in the database, but encrypted with the instance key.
Requirements of the scripts
- Run them on the Docker host, from a clone of the source code, with Docker and the
docker composev2 plugin. - They read the
.envfile and the Compose file at the root of the repository. UseENV_FILEandCOMPOSE_FILEto point at other paths. - They read
POSTGRES_USER,POSTGRES_DB,POSTGRES_PASSWORD,ENCRYPTION_KEY,APP_PORTandAPP_VERSIONfrom.env, and fall back on the defaults of the reference Compose file. - They read the storage settings as the application sees them: a throwaway container of the
appimage printsSTORAGE_DRIVER,STORAGE_ENDPOINT,STORAGE_REGION,STORAGE_BUCKET, the access keys,STORAGE_FORCE_PATH_STYLEandSTORAGE_FS_ROOTas the application would read them. The Compose file can therefore set or override any of them; see Where the blobs live. - Their messages are in English, and the restore asks you to type
YESto confirm.
Take a backup
bash
./scripts/backup.sh # writes to ./backups/<timestamp>
./scripts/backup.sh /mnt/backups # writes to another directoryThe instance keeps running during the backup. The script:
- dumps the database with
pg_dumpin PostgreSQL's custom format (compressed, and restorable in parallel), written directly on the host; - copies every blob into
blobs/: every object of the bucket with an S3 storage (mc mirror), or the whole directory withSTORAGE_DRIVER=fs; - writes
manifest.json; - deletes the oldest backups of the output directory beyond
BACKUP_KEEP(7 by default;0keeps them all). It only ever deletes directories that contain amanifest.json.
Each backup is a directory named after its UTC start time, for example 20261004T031500Z:
20261004T031500Z/
├── database.dump
├── blobs/
└── manifest.jsonThe directory is readable by its owner only: it contains emails in clear text. Treat it as sensitive data.
The script writes its progress on the standard error, and only the path of the new backup on the standard output, so that you can reuse it:
bash
BACKUP=$(./scripts/backup.sh /mnt/backups)
rsync -a "$BACKUP" backup-server:/srv/instance-backups/Schedule backups
Run the script from cron, as a user allowed to use Docker. For example, every night at 03:15:
cron
15 3 * * * cd /srv/instance && ./scripts/backup.sh /mnt/backups >> /var/log/instance-backup.log 2>&1Then copy the backups to another machine or another site: a backup that stays on the server's disk does not survive the loss of that disk.
The duration depends mostly on the number of objects in the storage, more than on the size of the database: each object is copied separately. Measure it on your instance, and check it again as the instance grows.
The manifest
manifest.json records what the backup contains:
json
{
"format": 1,
"startedAt": "2026-10-04T03:15:00Z",
"durationSeconds": 42,
"appVersion": "1.4.0",
"postgresVersion": "16.10",
"schemaVersion": "0052",
"database": { "name": "…", "user": "…", "dumpBytes": 104857600 },
"storage": { "driver": "s3", "bucket": "…", "objects": 48210, "bytes": 2147483648 },
"counts": { "mailboxes": 12, "messages": 310422 },
"encryptionKeyFingerprint": "f4ea96b23453dd75"
}| Field | What it tells you |
|---|---|
schemaVersion | The last database migration applied when the dump was taken. A backup can be restored into the same or a newer version of the application, never into an older one. |
appVersion | The APP_VERSION value of .env. Set APP_VERSION in .env to the version you run, so that this field means something. |
storage.driver | s3 or fs. A backup is restored only into an instance that uses the same driver: the two lay out their files differently. |
counts | Mailboxes and messages at backup time. Compare them after a restore. |
encryptionKeyFingerprint | A short digest of ENCRYPTION_KEY, not the key itself. The restore compares it with the key of your current .env. |
Read the manifest of your backups from time to time. A counts.messages of 0 in a small dump is a perfectly valid backup of an empty database.
Restore a backup
DANGER
A restore replaces the current database and overwrites the blobs: the objects of the bucket, or the whole directory with STORAGE_DRIVER=fs. Never run it on production "to see": use a copy.
Make sure
.envholds the sameENCRYPTION_KEYas when the backup was taken.Run the script with the backup directory:
bash./scripts/restore.sh backups/20261004T031500ZAnswer the confirmations by typing
YES.
The script runs these steps in this order:
- Checks, before writing anything. The dump, the manifest and the
blobs/directory must be present, and the storage driver of the backup must be the one the application uses now. The key fingerprint of the manifest is compared with the key of.env: if they differ, the script warns that every credential will be unreadable and asks for a confirmation. It shows how many messages the current database holds, then asks for a final confirmation. - Stops the application container. PostgreSQL is started if needed, and MinIO too when the Compose file has it and the application uses it.
- Recreates the database (
DROP DATABASE … WITH (FORCE), thenCREATE DATABASE) and restores the dump withpg_restore, four tables in parallel. It stops at the first error. - Refills the blob storage. With S3: creates the bucket if it is missing, keeps it private, and copies every object of the backup into it. If the provider refuses the privacy setting, the script warns and goes on: check the bucket policy in the provider's console. With
fs: empties the directory, then copies the backup into it. - Restarts the application and waits up to 180 seconds for
/readyzto answer200. Migrations newer than the backup are applied at that moment. - Prints control counts: members, mailboxes, threads, messages, workflows, live jobs and dead jobs. Compare
messageswithcounts.messagesof the manifest.
If pg_restore fails, the database is left partial: fix the cause and run the script again. RESTORE_YES=1 skips every confirmation; keep it for automated tests, never for a restore typed by hand.
After a restore
Sessions are restored too. Anyone who was signed in when the backup was taken is signed in again. To sign everyone out, delete the sessions:
bashdocker compose -f compose.reference.yaml exec postgres \ sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM sessions;"'Synchronisation resumes from the state recorded in the backup: the mirror catches up with what arrived at the providers since then.
Work created after the backup is lost: runs, workflow edits, tables, settings.
If you use an extra Compose file, run
docker compose up -dagain with all your files: the script restarted the application with its own Compose file only.
Restore on a new server
When the original server is gone:
- Install Docker and clone the source code, at the same version as the backup or a newer one.
- Put back the
.envfile you kept, with the sameENCRYPTION_KEY. - Build or pull the application image. See Self-hosting.
- Copy the backup directory to the server and run
./scripts/restore.sh <directory>. - Point the DNS and the reverse proxy at the new server. If the public address changes, follow Change the public address.
Test a restore without touching production
A backup you have never restored is not proven. Restore it on a throwaway copy, on the same machine, with its own project name and its own port:
Copy
.envtoverify.env, and in the copy set:iniCOMPOSE_PROJECT_NAME=instance-verify APP_PORT=55480 PUBLIC_BASE_URL=http://localhost:55480 SEND_ENABLED=false POLL_INTERVAL_SECONDS=86400With an external S3 storage, also set
STORAGE_BUCKETto a bucket dedicated to the check, and make sure your Compose file readsSTORAGE_BUCKETfrom the environment. MinIO and thefsdirectory belong to the copy's own volumes; an external bucket does not, and the restore would write the backup's objects into the production bucket.Restore into the copy and check it:
bashENV_FILE=verify.env RESTORE_YES=1 ./scripts/restore.sh backups/20261004T031500Z curl -s localhost:55480/readyzDestroy the copy and its volumes:
bashdocker compose --env-file verify.env -f compose.reference.yaml down -v
Never start a copy with sending open
A restored copy has the same workflows, mailboxes and credentials as production. Started as is, it sends real emails to real recipients. SEND_ENABLED=false holds every send. It does not hold the other operations: a workflow of the copy can still move, label or flag emails in the real mailboxes. Keep a copy running only as long as the check needs, and make sure COMPOSE_PROJECT_NAME is different from production before running down -v: with the same project name, that command deletes the production volumes.
Where the blobs live
MinIO (reference Compose file)
Nothing to do. The scripts run mc inside the Compose network, through the createbucket job that carries the mc image.
An external S3 storage: OVH Object Storage, AWS, Garage, Scaleway…
In compose.reference.yaml, replace the storage lines of the app service's environment block with your provider's, then remove the minio and createbucket services and the dependency of app on createbucket. For OVH Object Storage (S3, Gravelines region):
yaml
STORAGE_DRIVER: s3
STORAGE_ENDPOINT: https://s3.gra.io.cloud.ovh.net
STORAGE_REGION: gra
STORAGE_BUCKET: ${STORAGE_BUCKET:?}
STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:?}
STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:?}
STORAGE_FORCE_PATH_STYLE: 'false'Use the keys of an S3 user restricted to this bucket, and create the bucket as private in the provider's console. The scripts need nothing more: without a createbucket job, they run the mc image with docker run (MC_IMAGE, minio/mc:latest by default), which reaches the public endpoint. The copy time grows with the number of objects and the latency to the provider: run the backup from a machine close to the storage.
A directory: STORAGE_DRIVER=fs
Set STORAGE_DRIVER=fs in .env. The application keeps the blobs under STORAGE_FS_ROOT (./.data/blobs by default, that is /app/.data/blobs in the image), on the app-data volume that the reference Compose file mounts on /app/.data. MinIO is then useless.
The backup copies that directory as is, with tar through a throwaway container of the app image running as its own user; the restore empties the directory and fills it again. A backup taken with one driver cannot be restored with the other: the script refuses before writing anything. Switching the driver of an instance that already holds blobs is not supported: choose it at installation.
Restore only part of an instance
- One workflow, one member, one table. Do not restore over production. Restore the backup on a copy, as above, and copy what you need from the copy to production.
- The database without the blobs, or the reverse. The script always restores both. Each of its steps is a plain
docker composecommand: reuse the commands of the part you need.
What to avoid
- Copying the PostgreSQL volume while the database runs. A file copy of a live database is usually corrupt, and sometimes starts anyway.
pg_dumpgives a consistent snapshot. - Storing
ENCRYPTION_KEYnext to the backups. Anyone who gets both can decrypt access to every mailbox. - Restoring into an older version of the application. Migrations only go forward.
- Running
docker compose down -von production. It deletes the data volumes.