Guides and tutorials

Back up and restore Mailwake

Save your complete instance and verify that it restores.

This tutorial backs up Core configuration, access credentials, device grants, monitoring checkpoints and queued notifications. Original email messages remain with your email provider; this backup restores the Mailwake instance.

1. What to preserve

A recoverable backup contains:

  • The complete /data directory: mailwake.db, secret.key and related SQLite files. Keep the database and key together.
  • Compose configuration: ports, volumes and environment settings.
  • The running image: restore the same program version as the database.

The script below targets the standard Compose installation. It saves configuration and the image, briefly stops Core, copies all data, then restarts Core. Monitoring pauses during that interval. After restart, Core resumes from saved checkpoints and catches up messages still in the folder that were received within the last 24 hours.

If App integration uses a trust JSON file, reverse-proxy configuration or other host-mounted files, copy those files into the backup as well. Adjust their paths when restoring on another machine.

2. Create your first backup

Download the backup script, save it as backup.sh, and place it in your server installation directory, for example ~/mailwake/backup.sh.

View the backup script
#!/bin/sh
# Copyright (C) 2026 Mailwake contributors
# SPDX-License-Identifier: AGPL-3.0-only
# License: https://www.gnu.org/licenses/agpl-3.0.html
# Back up a running Compose installation; execute as a user with Docker access.
set -eu

if [ "$#" -ne 2 ]; then
  echo "Usage: backup.sh /absolute/compose-directory /absolute/backup-directory" >&2
  exit 2
fi
case "$1:$2" in
  /*:/*) ;;
  *) echo "Use absolute directory paths." >&2; exit 2 ;;
esac
cd "$1"
umask 077
lock=".mailwake-backup.lock"
if ! mkdir "$lock" 2>/dev/null; then
  echo "Could not create $PWD/$lock. Check directory permissions and active backups." >&2
  exit 1
fi
restart=0
cleanup() {
  result=$?
  trap - EXIT
  if [ "$restart" -eq 1 ]; then
    docker compose start core >&2 || result=1
  fi
  rmdir "$lock" || result=1
  exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
container=$(docker compose ps -q core)
if [ -z "$container" ]; then
  echo "Start the Core service before running this backup." >&2
  exit 1
fi
mkdir -p "$2"
backup="$2/mailwake-$(date +%Y%m%d-%H%M%S)"
test ! -e "$backup"
staging="$backup.incomplete"
mkdir "$staging"
mkdir "$staging/data"
docker compose config > "$staging/compose.yaml"
docker inspect --format '{{.Image}}' "$container" > "$staging/image-id.txt"
docker image save --output "$staging/image.tar" "$(cat "$staging/image-id.txt")"
restart=1
docker compose stop core >&2
docker compose cp core:/data/. "$staging/data/" >&2
docker compose start core >&2
restart=0
mv "$staging" "$backup"
printf 'Backup saved: %s\n' "$backup"

Run in your server terminal:

sh "$HOME/mailwake/backup.sh" "$HOME/mailwake" "$HOME/mailwake-backups"

Check the result: the terminal prints Backup saved: with the new directory path, and docker compose ps shows Core running again. If copying fails, the script attempts to restart Core and returns a failure. Directories ending in .incomplete are unfinished backups; keep them for troubleshooting.

The lock directory prevents overlapping scheduled and manual backups. If the host crashes or the process is forcibly killed, first confirm that no backup is running, then remove .mailwake-backup.lock from the installation directory and rerun the script.

Each backup has this layout:

mailwake-20261008-030000/
  compose.yaml
  image-id.txt
  image.tar
  data/
    mailwake.db
    secret.key
    ...

Saving the image requires additional disk space and happens before Core stops. The directory uses private permissions and contains both the encrypted credentials and their key. Confirm data/mailwake.db, data/secret.key and image.tar exist, then perform the restore exercise below at least once.

3. Restore into a new volume

Stop the original instance and keep its original volume. Both instances share mailbox and device identities, so run only one at a time.

cd ~/mailwake
docker compose stop core

Set backup to your actual backup path. Prepare a new installation directory, its image and configuration:

backup="$HOME/mailwake-backups/mailwake-20261008-030000"
mkdir -p ~/mailwake-restored
cd ~/mailwake-restored
cp "$backup/compose.yaml" ./compose.yaml
docker load --input "$backup/image.tar"
restore_volume="mailwake-restored-$(date +%Y%m%d-%H%M%S)"
cat > compose.override.yaml <<EOF
name: mailwake-restored
services:
  core:
    image: $(cat "$backup/image-id.txt")
volumes:
  core-data:
    name: $restore_volume
networks:
  default:
    name: $restore_volume-network
EOF

Compose automatically loads compose.override.yaml. It selects a fresh data volume and the exact backed-up image. Create the container, copy data, set ownership for the container user, and start:

(
  set -eu
  docker compose create --pull never core
  docker compose run --rm --no-deps -T --user 0:0 --entrypoint sh core \
    -c 'entries=$(ls -A /data) && test -z "$entries"'
  docker compose cp "$backup/data/." core:/data/
  docker compose run --rm --no-deps -T --user 0:0 --cap-add CHOWN --entrypoint chown core \
    -R 10001:10001 /data
  docker compose up -d --pull never
  docker compose logs --tail=50 core
)

Continue only after each command succeeds. If the empty-volume check fails, choose a fresh restore_volume, rewrite the override and recreate the container. A populated destination could mix old files with restored data; this procedure uses an empty volume.

Check the result: sign in with your original administrator password. Mailboxes and folder selections remain present, monitoring resumes, and device grants come from the backup. If you see a new setup screen, stop the restored instance and check the copy path and volume mount. For decryption errors, confirm the database and secret.key came from the same backup.

4. Verify the restored instance

  1. Check mailbox counts, selected folders and check methods against the original installation.
  2. Send a channel test notification and confirm it arrives on your phone.
  3. Send a new email from another mailbox into a watched folder and check its delivery record.
  4. Restart the restored instance, sign in again, and confirm its configuration and monitoring persist.

Once verified, point the reverse proxy at the restored instance and manage it from ~/mailwake-restored. Update the backup job to use that directory. Leave the original instance stopped and retain its volume and the backup until recovery is confirmed.

Pending or failed notifications return to their backed-up state and may be delivered again. Use the same procedure to roll back an upgrade, restoring the old data and image together.

5. Schedule backups and keep an off-site copy

On a Linux server, first complete a manual backup, then use crontab -e to add the following. Replace /home/you with your absolute paths:

PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /bin/sh /home/you/mailwake/backup.sh /home/you/mailwake /home/you/mailwake-backups >> /home/you/mailwake-backup.log 2>&1

This runs daily at 03:00 in the server's local timezone. The scheduled user needs Docker access. On a NAS, use the same Shell command in its task scheduler. The following day, check for a new directory and Backup saved: in the log. Address failures; a scheduler cannot establish that a backup is recoverable.

A backup on the same disk does not cover disk or machine failure. Copy completed directories to another machine or protected backup storage, for example over SSH:

scp -r "$HOME/mailwake-backups/mailwake-20261008-030000" \
  backup-user@backup-host:/srv/mailwake-backups/

Replace the source, remote user, host and destination. Restrict access at the destination to backup maintainers. Use encrypted storage or encrypt the archive before uploading when encryption at rest is needed. A possible retention policy is seven daily and four weekly copies. Before deleting older backups, verify the off-site copy and a recent restore exercise.

Back up a Docker Run installation

The Docker Run installation uses a container named mailwake and volume mailwake-data. These commands run in a subshell and attempt to restart the original container if copying fails:

(
  set -eu
  umask 077
  backup="$HOME/mailwake-backups/mailwake-$(date +%Y%m%d-%H%M%S)"
  mkdir -p "$backup/data"
  docker inspect --format '{{.Image}}' mailwake > "$backup/image-id.txt"
  docker image save --output "$backup/image.tar" "$(cat "$backup/image-id.txt")"
  trap 'docker start mailwake >/dev/null' EXIT
  docker stop mailwake
  docker cp mailwake:/data/. "$backup/data/"
  docker start mailwake
  trap - EXIT
  printf 'Backup saved: %s\n' "$backup"
)

Save your Docker Run command alongside the data. To restore, stop the original container, load image.tar, create a new volume and container, and copy data before starting:

(
  set -eu
  backup="$HOME/mailwake-backups/mailwake-20261008-030000"
  image=$(cat "$backup/image-id.txt")
  restore_volume="mailwake-restored-$(date +%Y%m%d-%H%M%S)"
  docker stop mailwake
  docker load --input "$backup/image.tar"
  docker volume create "$restore_volume"
  docker create --name mailwake-restored --restart unless-stopped \
    --stop-timeout 30 --read-only --security-opt no-new-privileges:true --cap-drop ALL \
    -p 127.0.0.1:8080:8080 -v "$restore_volume:/data" "$image"
  docker run --rm --user 0:0 --entrypoint sh -v "$restore_volume:/data" "$image" \
    -c 'entries=$(ls -A /data) && test -z "$entries"'
  docker cp "$backup/data/." mailwake-restored:/data/
  docker run --rm --user 0:0 --entrypoint chown -v "$restore_volume:/data" "$image" \
    -R 10001:10001 /data
  docker start mailwake-restored
)

Continue only after each command succeeds and follow the verification checklist above. Keep the original container and volume, leaving the original container stopped while the restored instance runs.