RAID Is Not a Backup
Let's get this out of the way: RAID protects against disk failure. Period. It does NOT protect against:
- Accidental deletion (rm -rf, docker volume prune at 2 AM)
- Ransomware (encrypts your data on all RAID members simultaneously)
- Bad updates (corrupted database after a container upgrade)
- Human error (docker compose down -v — the -v removes volumes)
- Fire, flood, or theft (all disks are in the same box)
If you can't recover your data from a completely separate machine or location, you don't have backups. You have redundancy, which is a different thing.
What to Back Up
Not everything in Docker needs backing up. Here's what matters:
MUST back up:
- Named volumes — these contain your persistent data (databases, configs, uploaded files, vault data)
- Docker compose files — your service definitions (docker-compose.yml)
- Environment files — .env files with configuration values
- Custom configs — Caddyfile, Technitium zones, any config file mounted as a volume
Nice to have:
- Container logs (docker logs output) — useful for post-mortem debugging
- Docker daemon config (/etc/docker/daemon.json)
Do NOT back up:
- Container images — these are pulled from registries. Your compose file is the recipe.
- Anonymous volumes — if you didn't name it, it probably doesn't matter
- Build caches — rebuild from Dockerfiles
Step 1: Organize Your Docker Setup
The backup script assumes your compose files are organized in directories. If they're not already, restructure like this:
~/docker/
technitium/
docker-compose.yml
vaultwarden/
docker-compose.yml
.env
caddy/
docker-compose.yml
Caddyfile
sonarr/
docker-compose.yml
Each service gets its own directory with its compose file and any related config. If your setup is different, you'll need to adjust the backup script's COMPOSE_DIR variable.
Check your current compose file locations:
find ~ /opt -name 'docker-compose.yml' -o -name 'compose.yml' 2>/dev/nullStep 2: List Your Named Volumes
See what named volumes exist on this host. These are the data stores we need to back up:
docker volume ls --format 'table {{.Name}}\t{{.Driver}}' | sortInterpret the Output
You'll see something like:
VOLUME NAME DRIVER
caddy_caddy_config local
caddy_caddy_data local
technitium-data local
uptime-kuma-data local
vw-data local
Every volume here contains data that will be LOST if the host dies. Make a mental note of which ones are critical.
Step 3: Create the Backup Directory
Choose where backups will be stored. Ideally this is a different disk or a mounted network share (NFS/CIFS from your NAS).
For local backups (different disk or partition):
sudo mkdir -p /backups/docker
sudo chown $USER:$USER /backups/dockerStep 4: Create the Backup Script
This script backs up all compose directories and all named Docker volumes. It's designed to be safe (read-only access to volumes), efficient (compressed tar archives), and self-maintaining (auto-deletes backups older than 7 days):
cat > ~/docker/backup-docker.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail
# === Configuration ===
COMPOSE_DIR="$HOME/docker" # Directory containing your compose project folders
BACKUP_DIR="/backups/docker" # Where to store backups
RETENTION_DAYS=7 # Delete backups older than this
DATE=$(date +%Y-%m-%d_%H%M)
TODAY_DIR="$BACKUP_DIR/$DATE"
# === Create today's backup directory ===
mkdir -p "$TODAY_DIR/compose"
mkdir -p "$TODAY_DIR/volumes"
echo "[$(date)] Starting Docker backup to $TODAY_DIR"
# === Back up compose directories ===
echo "[compose] Backing up compose files..."
for dir in "$COMPOSE_DIR"/*/; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
# Skip the backup script's own directory
[ "$name" = "backups" ] && continue
tar czf "$TODAY_DIR/compose/${name}.tar.gz" -C "$dir" . 2>/dev/null
echo " [compose] $name"
done
# === Back up named volumes ===
echo "[volumes] Backing up Docker volumes..."
for vol in $(docker volume ls -q); do
echo " [volumes] $vol"
# Mount the volume read-only in a temporary Alpine container and tar it
docker run --rm \
-v "$vol":/source:ro \
-v "$TODAY_DIR/volumes":/backup \
alpine \
tar czf "/backup/${vol}.tar.gz" -C /source . 2>/dev/null
done
# === Calculate backup size ===
SIZE=$(du -sh "$TODAY_DIR" | cut -f1)
echo "[done] Backup complete: $TODAY_DIR ($SIZE)"
# === Clean up old backups ===
if [ -d "$BACKUP_DIR" ]; then
OLD=$(find "$BACKUP_DIR" -maxdepth 1 -mindepth 1 -type d -mtime +$RETENTION_DAYS)
if [ -n "$OLD" ]; then
echo "[cleanup] Removing backups older than $RETENTION_DAYS days:"
echo "$OLD" | while read -r d; do
echo " [cleanup] $(basename "$d")"
rm -rf "$d"
done
fi
fi
echo "[$(date)] Backup finished successfully"
SCRIPT
chmod +x ~/docker/backup-docker.shStep 5: Test the Script
Run it manually first and watch the output:
~/docker/backup-docker.shValidate: What You Should See
The script should output each compose directory and volume being backed up:
[2026-04-05 10:30:00] Starting Docker backup to /backups/docker/2026-04-05_1030
[compose] Backing up compose files...
[compose] caddy
[compose] technitium
[compose] vaultwarden
[volumes] Backing up Docker volumes...
[volumes] caddy_caddy_config
[volumes] caddy_caddy_data
[volumes] technitium-data
[volumes] vw-data
[done] Backup complete: /backups/docker/2026-04-05_1030 (45M)
[2026-04-05 10:30:15] Backup finished successfully
Verify the files were created:
ls -la /backups/docker/$(ls /backups/docker/ | tail -1)/volumes/ | head -10Common Errors
- "Cannot connect to the Docker daemon" — run with a user in the docker group or prepend sudo
- "Error response from daemon: No such volume" — a volume was deleted between listing and backing up. Safe to ignore.
- "tar: Cannot open: Permission denied" — the backup directory permissions are wrong. Fix: sudo chown $USER:$USER /backups/docker
- Very slow on large volumes — this is normal for big databases or media volumes. The script is I/O bound. Run during off-hours.
Step 6: Schedule Nightly Backups
Add the script to cron to run every night at 3 AM. The output goes to a log file so you can check what happened:
(crontab -l 2>/dev/null; echo '0 3 * * * $HOME/docker/backup-docker.sh >> /var/log/docker-backup.log 2>&1') | crontab -Step 6b: Verify the Cron Job
Confirm the cron entry was added:
crontab -l | grep backupExpected Output
You should see:
0 3 * * * /home/youruser/docker/backup-docker.sh >> /var/log/docker-backup.log 2>&1
Create the log file so the first run doesn't fail:
sudo touch /var/log/docker-backup.log && sudo chown $USER:$USER /var/log/docker-backup.log
Step 7: Test a Restore
A backup you've never restored from is Schrodinger's backup — it might work, it might not. Test it now.
Restore a single volume to a test volume:
# Pick a backup file
BACKUP_FILE=$(ls /backups/docker/*/volumes/*.tar.gz | head -1)
echo "Testing restore of: $BACKUP_FILE"
# Create a test volume
docker volume create test-restore
# Restore into it
docker run --rm \
-v test-restore:/target \
-v $(dirname $BACKUP_FILE):/backup:ro \
alpine \
tar xzf "/backup/$(basename $BACKUP_FILE)" -C /target
# List the contents to verify
docker run --rm -v test-restore:/data alpine ls -la /data
# Clean up
docker volume rm test-restoreValidate: What You Should See
The ls command should show the actual contents of the volume — config files, database files, whatever the service stores. If you see the expected files, your backup and restore process works.
If the archive is empty or corrupt, check that the backup script ran without errors and that the source volume actually had data in it.
Step 8: Offsite Copy (Critical)
Local backups protect against container failures and accidental deletion. They do NOT protect against disk failure, host failure, or physical disaster. Copy your backups to another machine.
Option A — rsync to a NAS or another server:
# Add to cron, runs after the backup script (4 AM)
# 0 4 * * * rsync -avz --delete /backups/docker/ nas:/backups/docker-from-host/
rsync -avz --delete /backups/docker/ [email protected]:/mnt/backups/docker/
Option B — rsync to offsite (VPS, cloud storage):
rsync -avz -e ssh /backups/docker/ user@your-vps:/backups/docker/
Option C — rclone to cloud storage (S3, B2, Google Drive):
rclone sync /backups/docker/ remote:docker-backups/
Any of these work. The point is: your backups should exist in at least two physically separate locations.
Database-Specific Backups
For databases (PostgreSQL, MySQL, MongoDB), tar-ing the volume files while the database is running can produce an inconsistent backup. The safe approach is to dump the database first:
PostgreSQL:
docker exec postgres pg_dumpall -U postgres > ~/backups/postgres-$(date +%Y%m%d).sql
MySQL/MariaDB:
docker exec mysql mysqldump -u root -p'password' --all-databases > ~/backups/mysql-$(date +%Y%m%d).sql
SQLite (used by Vaultwarden, Uptime Kuma):
docker exec vaultwarden sqlite3 /data/db.sqlite3 .dump > ~/backups/vaultwarden-$(date +%Y%m%d).sql
Add these dump commands to the backup script BEFORE the volume backup step for maximum safety. The volume backup serves as a belt-and-suspenders second copy.
How to Undo Everything
To remove the backup system:
1. Remove the cron job:
crontab -l | grep -v backup | crontab -
2. Delete the backup script:
rm ~/docker/backup-docker.sh
3. Delete the backups (careful — this is irreversible):
rm -rf /backups/docker
4. Delete the log:
rm /var/log/docker-backup.log
To do a full restore from backup after a disaster:
1. Install Docker on the new host
2. Copy the backup directory to the new host
3. Restore compose files: tar xzf /backups/docker/DATE/compose/SERVICE.tar.gz -C ~/docker/SERVICE/
4. Restore volumes: for each .tar.gz, create the volume and restore (see Step 7)
5. docker compose up -d in each service directory