Skip to content

Backups and restoring

For whoever is running the desk.

Three things matter, and only three:

  1. The database: tickets, clients, settings, everything anybody typed.
  2. The storage volume: attachments, inbound message originals, the location database, knowledge uploads.
  3. .env: your configuration. On a container install the application key is not here by default; it is generated on first start and kept in the storage volume as app-key, which is why item 2 is not only attachments. Without that key, encrypted values in the database (mailbox passwords, platform secrets) cannot be read back, even from a perfect database restore.

Everything else is an image you can pull again, with one worth knowing about and one worth not doing:

  • desk-tls holds the certificates and the certificate-authority account key. Not essential, because they are re-requested automatically, but authorities limit how often you may ask for the same name, so a copy saves an awkward afternoon. On a restore to a different machine, deliberately leave it behind: you want certificates fetched fresh for wherever the desk now lives.
  • desk-cache is deliberately not in this list, even though it holds queued work as well as the cache. Restoring a day-old queue would re-run a day-old queue, sending email that was already sent. What protects it is durability rather than backups: it is written with an append-only log, so an ungraceful stop loses at most a second, and a polite docker compose stop loses nothing.

A nightly backup

#!/bin/bash
# /usr/local/bin/desk-backup, run from the directory holding docker-compose.yml
set -euo pipefail
cd /srv/support-desk
set -a; . ./.env; set +a
STAMP=$(date +%F)
DEST=/var/backups/desk
mkdir -p "$DEST"
docker compose exec -T db mariadb-dump -u root -p"$DB_ROOT_PASSWORD" \
--single-transaction --quick --routines "$DB_DATABASE" | gzip > "$DEST/db-$STAMP.sql.gz"
# Not "is there a file". A dump that died half way, the connection dropped, the disk filled, a table it
# could not read, still gzips to something non-empty, and `test -s` waves it through. So: the archive has
# to be intact, and it has to carry the closing line mariadb-dump only writes when it reached the end.
gzip -t "$DEST/db-$STAMP.sql.gz"
zcat "$DEST/db-$STAMP.sql.gz" | tail -3 | grep -q "^-- Dump completed"
docker run --rm -v support-desk_desk-storage:/data -v "$DEST":/backup alpine \
tar czf "/backup/storage-$STAMP.tgz" -C /data .
cp ./.env "$DEST/env-$STAMP"
find "$DEST" -mtime +14 -delete
# Off the box, which is the only copy that survives losing the host
# rclone copy "$DEST" remote:desk-backups --min-age 1m
Terminal window
chmod 700 /usr/local/bin/desk-backup
echo '15 3 * * * root /usr/local/bin/desk-backup' > /etc/cron.d/desk-backup

Check the volume’s real name with docker volume ls, Compose prefixes it with the project directory.

An off-box copy is the backup. A dump on the same disk as the database protects you from a bad migration and from nothing else.

Restoring

Onto a clean host, in this order:

Terminal window
# 1. The compose file and the .env you backed up, the APP_KEY must be the original
mkdir support-desk && cd support-desk
cp /path/to/backup/env-2026-09-14 .env
curl -O https://registry.sixnix.net/install/docker-compose.yml
# 2. Start only the database and cache
docker compose up -d db cache
# 3. Put the data back
zcat /path/to/backup/db-2026-09-14.sql.gz | docker compose exec -T db mariadb -u root -p"$DB_ROOT_PASSWORD" support
docker volume create support-desk_desk-storage
docker run --rm -v support-desk_desk-storage:/data -v /path/to/backup:/backup alpine \
tar xzf /backup/storage-2026-09-14.tgz -C /data
# 4. Everything else
docker compose up -d
docker compose logs -f web

Then check, in this order: sign in; open a ticket with an attachment and confirm the file downloads; Settings → Health; send a test email; and if you use mailboxes, confirm each still signs in, an OAuth mailbox may need reconnecting if the restore is onto a different hostname.

On a server install

Same three things, different commands:

Terminal window
mariadb-dump --single-transaction --quick --routines support | gzip > db-$(date +%F).sql.gz
tar czf files-$(date +%F).tar.gz -C /home/www/support/app storage/app .env

Repository mirrors can be large. If you use the issue tracker’s repository feature, exclude storage/app/private/repositories and let them be re-cloned, they are copies of something that exists elsewhere.

Testing the restore

A backup nobody has restored is a hypothesis. Once a quarter, restore last night’s copies onto a scratch host and open a ticket on it. Twenty minutes, and it is the only way to discover that something you never thought about (the application key, a volume name, a file permission) was not in the backup.