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
/datadirectory:mailwake.db,secret.keyand 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
- Check mailbox counts, selected folders and check methods against the original installation.
- Send a channel test notification and confirm it arrives on your phone.
- Send a new email from another mailbox into a watched folder and check its delivery record.
- 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.