Backup Strategies¶
What to back up, how often, and where to store it — because the question isn't if you'll need a backup, it's when.
What Needs Backing Up¶
| Data | Priority | Size | Changes How Often |
|---|---|---|---|
| MySQL database | Critical | Medium | Constantly |
| Mail storage (Maildir) | Critical | Large | Constantly |
| Configuration files | High | Small | Rarely |
| TLS certificates | High | Tiny | Every 60-90 days |
| DKIM keys | High | Tiny | Rarely |
| Rspamd learned data | Medium | Small | Daily |
| Docker Compose files | Medium | Tiny | Occasionally |
.env file | High | Tiny | Occasionally |
Things you don't need to back up: - Redis data (rebuilt from the database on restart) - Postfix queue (transient by nature) - Prometheus data (nice to have, not critical) - Docker images (pulled from registry)
Backup Schedule¶
| Backup Type | Frequency | Retention |
|---|---|---|
| MySQL full dump | Daily | 30 days |
| MySQL binary log | Continuous | 7 days |
| Mail storage incremental | Daily | 30 days |
| Mail storage full | Weekly | 90 days |
| Configuration snapshot | On change + weekly | 90 days |
MySQL Backup¶
Option 1: mysqldump (Simple, good for small databases)¶
Bash
#!/bin/bash
# scripts/backup-mysql.sh
BACKUP_DIR="/opt/mailyte/backups/mysql"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/mailyte_${TIMESTAMP}.sql.gz"
mkdir -p "$BACKUP_DIR"
# Dump and compress
docker compose exec -T mysql mysqldump \
-u root -p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction \
--routines \
--triggers \
--databases mailyte \
| gzip > "$BACKUP_FILE"
# Verify the backup isn't empty
if [ -s "$BACKUP_FILE" ]; then
echo "$(date): MySQL backup created: $BACKUP_FILE ($(du -sh "$BACKUP_FILE" | cut -f1))"
else
echo "$(date): ERROR: MySQL backup is empty!"
rm -f "$BACKUP_FILE"
exit 1
fi
# Remove backups older than 30 days
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete
Option 2: Percona XtraBackup (Better for large databases)¶
Bash
# Install xtrabackup in the MySQL container
docker compose exec mysql apt-get install -y percona-xtrabackup-80
# Full backup
docker compose exec mysql xtrabackup \
--backup \
--target-dir=/var/backups/mysql/full \
--user=root \
--password="${MYSQL_ROOT_PASSWORD}"
# Incremental backup (based on the last full)
docker compose exec mysql xtrabackup \
--backup \
--target-dir=/var/backups/mysql/inc1 \
--incremental-basedir=/var/backups/mysql/full \
--user=root \
--password="${MYSQL_ROOT_PASSWORD}"
Mail Storage Backup¶
Using rsync (Incremental)¶
Bash
#!/bin/bash
# scripts/backup-mail.sh
BACKUP_DIR="/opt/mailyte/backups/mail"
TIMESTAMP=$(date +%Y%m%d)
MAIL_VOLUME=$(docker volume inspect mailyte_mail-data --format '{{ .Mountpoint }}')
mkdir -p "$BACKUP_DIR"
# Incremental backup with rsync
rsync -av --delete \
"$MAIL_VOLUME/" \
"$BACKUP_DIR/latest/"
# Create a dated snapshot using hard links (saves space)
cp -al "$BACKUP_DIR/latest" "$BACKUP_DIR/snapshot_${TIMESTAMP}"
# Remove snapshots older than 30 days
find "$BACKUP_DIR" -maxdepth 1 -name "snapshot_*" -mtime +30 -exec rm -rf {} +
echo "$(date): Mail backup complete"
Using tar (Full archive)¶
Bash
# Weekly full backup
MAIL_VOLUME=$(docker volume inspect mailyte_mail-data --format '{{ .Mountpoint }}')
tar czf /opt/mailyte/backups/mail/mail_full_$(date +%Y%m%d).tar.gz \
-C "$MAIL_VOLUME" .
Configuration Backup¶
Bash
#!/bin/bash
# scripts/backup-config.sh
BACKUP_DIR="/opt/mailyte/backups/config"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
tar czf "$BACKUP_DIR/config_${TIMESTAMP}.tar.gz" \
--exclude='.env' \
docker-compose.yml \
docker-compose.monitoring.yml \
config/ \
monitoring/ \
scripts/
# Back up .env separately (encrypted)
gpg --symmetric --cipher-algo AES256 \
--output "$BACKUP_DIR/env_${TIMESTAMP}.gpg" \
.env
# Keep 90 days
find "$BACKUP_DIR" -mtime +90 -delete
echo "$(date): Config backup complete"
Remote Storage¶
Don't keep backups only on the same server. Use remote storage.
Amazon S3¶
Bash
#!/bin/bash
# scripts/sync-to-s3.sh
AWS_BUCKET="s3://your-bucket/mailyte-backups"
BACKUP_DIR="/opt/mailyte/backups"
# Sync all backups to S3
aws s3 sync "$BACKUP_DIR" "$AWS_BUCKET" \
--storage-class STANDARD_IA \
--delete
# For older backups, use Glacier
aws s3api put-bucket-lifecycle-configuration \
--bucket your-bucket \
--lifecycle-configuration '{
"Rules": [{
"ID": "archive-old-backups",
"Filter": {"Prefix": "mailyte-backups/"},
"Status": "Enabled",
"Transitions": [{
"Days": 30,
"StorageClass": "GLACIER"
}],
"Expiration": {"Days": 365}
}]
}'
Azure Blob Storage¶
Bash
# Install Azure CLI
# az login
az storage blob upload-batch \
--destination mailyte-backups \
--source /opt/mailyte/backups \
--account-name yourstorageaccount \
--overwrite
Rsync to Remote Server¶
Bash
# Sync to a backup server
rsync -avz --delete \
/opt/mailyte/backups/ \
backup-user@backup-server:/backups/mailyte/
Full Backup Script¶
Wraps everything together:
Bash
#!/bin/bash
# scripts/backup.sh — Full Mailyte backup
set -e
LABEL="${1:-daily}"
BACKUP_ROOT="/opt/mailyte/backups"
LOG="/var/log/mailyte-backup.log"
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" | tee -a "$LOG"; }
log "Starting $LABEL backup"
# 1. MySQL
log "Backing up MySQL..."
./scripts/backup-mysql.sh >> "$LOG" 2>&1
# 2. Mail storage
log "Backing up mail storage..."
./scripts/backup-mail.sh >> "$LOG" 2>&1
# 3. Configuration
log "Backing up configuration..."
./scripts/backup-config.sh >> "$LOG" 2>&1
# 4. Sync to remote (uncomment the one you use)
# log "Syncing to S3..."
# ./scripts/sync-to-s3.sh >> "$LOG" 2>&1
# log "Syncing to remote server..."
# rsync -avz $BACKUP_ROOT/ backup-user@backup-server:/backups/mailyte/ >> "$LOG" 2>&1
log "Backup complete. Total size: $(du -sh $BACKUP_ROOT | cut -f1)"
Schedule it:
Bash
# Daily at 2 AM
echo "0 2 * * * root /opt/mailyte/scripts/backup.sh daily >> /var/log/mailyte-backup.log 2>&1" \
| sudo tee /etc/cron.d/mailyte-backup
# Weekly full on Sundays at 1 AM
echo "0 1 * * 0 root /opt/mailyte/scripts/backup.sh weekly >> /var/log/mailyte-backup.log 2>&1" \
| sudo tee -a /etc/cron.d/mailyte-backup
Verifying Backups¶
A backup you haven't tested is not a backup.
Bash
# Test MySQL restore to a temporary database
docker run --rm -v /opt/mailyte/backups/mysql:/backups mysql:8.0 \
bash -c "
mysqld --skip-grant-tables &
sleep 10
zcat /backups/mailyte_latest.sql.gz | mysql
mysql -e 'SELECT COUNT(*) FROM mailyte.users;'
echo 'Restore test passed'
"
# Test config archive
tar tzf /opt/mailyte/backups/config/config_latest.tar.gz | head -20
Tip: Schedule a monthly "restore drill" where you restore from backup to a test environment. It takes an hour and saves you days of panic during a real disaster.