Skip to content

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 livesIn the backup made by backup.sh
State: members, mailboxes, workflows, runs, the job queue, tables, sessions, encrypted credentialsPostgreSQLYes: database.dump
Email bodies, attachments, files added to runsBlob storage: an S3 bucket, or a directory with STORAGE_DRIVER=fsYes: blobs/
ENCRYPTION_KEYYour .env fileNo, 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 .env file: 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 compose v2 plugin.
  • They read the .env file and the Compose file at the root of the repository. Use ENV_FILE and COMPOSE_FILE to point at other paths.
  • They read POSTGRES_USER, POSTGRES_DB, POSTGRES_PASSWORD, ENCRYPTION_KEY, APP_PORT and APP_VERSION from .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 app image prints STORAGE_DRIVER, STORAGE_ENDPOINT, STORAGE_REGION, STORAGE_BUCKET, the access keys, STORAGE_FORCE_PATH_STYLE and STORAGE_FS_ROOT as 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 YES to confirm.

Take a backup ​

bash
./scripts/backup.sh                  # writes to ./backups/<timestamp>
./scripts/backup.sh /mnt/backups     # writes to another directory

The instance keeps running during the backup. The script:

  1. dumps the database with pg_dump in PostgreSQL's custom format (compressed, and restorable in parallel), written directly on the host;
  2. copies every blob into blobs/: every object of the bucket with an S3 storage (mc mirror), or the whole directory with STORAGE_DRIVER=fs;
  3. writes manifest.json;
  4. deletes the oldest backups of the output directory beyond BACKUP_KEEP (7 by default; 0 keeps them all). It only ever deletes directories that contain a manifest.json.

Each backup is a directory named after its UTC start time, for example 20261004T031500Z:

20261004T031500Z/
├── database.dump
├── blobs/
└── manifest.json

The 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>&1

Then 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"
}
FieldWhat it tells you
schemaVersionThe 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.
appVersionThe APP_VERSION value of .env. Set APP_VERSION in .env to the version you run, so that this field means something.
storage.drivers3 or fs. A backup is restored only into an instance that uses the same driver: the two lay out their files differently.
countsMailboxes and messages at backup time. Compare them after a restore.
encryptionKeyFingerprintA 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.

  1. Make sure .env holds the same ENCRYPTION_KEY as when the backup was taken.

  2. Run the script with the backup directory:

    bash
    ./scripts/restore.sh backups/20261004T031500Z
  3. Answer the confirmations by typing YES.

The script runs these steps in this order:

  1. 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.
  2. Stops the application container. PostgreSQL is started if needed, and MinIO too when the Compose file has it and the application uses it.
  3. Recreates the database (DROP DATABASE … WITH (FORCE), then CREATE DATABASE) and restores the dump with pg_restore, four tables in parallel. It stops at the first error.
  4. 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.
  5. Restarts the application and waits up to 180 seconds for /readyz to answer 200. Migrations newer than the backup are applied at that moment.
  6. Prints control counts: members, mailboxes, threads, messages, workflows, live jobs and dead jobs. Compare messages with counts.messages of 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:

    bash
    docker 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 -d again 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:

  1. Install Docker and clone the source code, at the same version as the backup or a newer one.
  2. Put back the .env file you kept, with the same ENCRYPTION_KEY.
  3. Build or pull the application image. See Self-hosting.
  4. Copy the backup directory to the server and run ./scripts/restore.sh <directory>.
  5. 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:

  1. Copy .env to verify.env, and in the copy set:

    ini
    COMPOSE_PROJECT_NAME=instance-verify
    APP_PORT=55480
    PUBLIC_BASE_URL=http://localhost:55480
    SEND_ENABLED=false
    POLL_INTERVAL_SECONDS=86400

    With an external S3 storage, also set STORAGE_BUCKET to a bucket dedicated to the check, and make sure your Compose file reads STORAGE_BUCKET from the environment. MinIO and the fs directory 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.

  2. Restore into the copy and check it:

    bash
    ENV_FILE=verify.env RESTORE_YES=1 ./scripts/restore.sh backups/20261004T031500Z
    curl -s localhost:55480/readyz
  3. Destroy the copy and its volumes:

    bash
    docker 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 compose command: 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_dump gives a consistent snapshot.
  • Storing ENCRYPTION_KEY next 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 -v on production. It deletes the data volumes.