Skip to content

Instantly share code, notes, and snippets.

@KristianP26
Last active May 21, 2026 07:45
Show Gist options
  • Select an option

  • Save KristianP26/d4034a9263aa6298bb2f9f656fe57424 to your computer and use it in GitHub Desktop.

Select an option

Save KristianP26/d4034a9263aa6298bb2f9f656fe57424 to your computer and use it in GitHub Desktop.
Auto-discovery Docker backup to Cloudflare R2: volumes + DB dumps + bind mounts, encrypted via rclone, with retention and Healthchecks.io monitoring. No config needed for new containers.
#!/bin/bash
# Auto-discovery Docker backup script
# Automatically finds and backs up ALL Docker volumes and databases.
# Uploads to Cloudflare R2, then cleans up local files.
# No configuration needed when adding new containers.
set -euo pipefail
HC_BACKUP_URL="${HC_BACKUP_URL:?set HC_BACKUP_URL}"
# Signal start to Healthchecks.io
curl -fsS --max-time 10 --retry 3 "${HC_BACKUP_URL}/start" > /dev/null 2>&1 || true
BACKUP_DIR="${BACKUP_DIR:-/tmp/backup-staging}"
VOLUME_SOURCE="${VOLUME_SOURCE:-/source/volumes}"
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-7}"
R2_BUCKET="${R2_BUCKET:-homelab-backups}"
R2_REMOTE="${R2_REMOTE:-r2}"
DATE=$(date +%Y-%m-%d_%H-%M-%S)
BACKUP_PATH="${BACKUP_DIR}/${DATE}"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
cleanup() {
log "Cleaning up local staging directory..."
rm -rf "${BACKUP_PATH}"
}
trap cleanup EXIT
log "=== Starting backup ==="
mkdir -p "${BACKUP_PATH}/volumes" "${BACKUP_PATH}/db-dumps"
# ============================================================
# Phase 1: Database dumps (BEFORE volume backup for consistency)
# Auto-detects databases by container image name.
# ============================================================
log "Phase 1: Database dumps (auto-detection by image)"
docker ps --format '{{.ID}}\t{{.Names}}\t{{.Image}}' | while IFS=$'\t' read -r id name image; do
case "$image" in
*mariadb*|*mysql*)
log " Dumping MariaDB/MySQL: ${name}"
# Try root first, then fall back to MYSQL_USER (handles MYSQL_RANDOM_ROOT_PASSWORD)
if docker exec "$id" sh -c \
'mariadb-dump -u root -p"${MARIADB_ROOT_PASSWORD:-${MYSQL_ROOT_PASSWORD:-}}" --all-databases --no-tablespaces 2>/dev/null' \
> "${BACKUP_PATH}/db-dumps/${name}.sql" 2>/dev/null && [ -s "${BACKUP_PATH}/db-dumps/${name}.sql" ]; then
log " OK ($(du -h "${BACKUP_PATH}/db-dumps/${name}.sql" | cut -f1))"
elif docker exec "$id" sh -c \
'mariadb-dump -u"${MYSQL_USER}" -p"${MYSQL_PASSWORD}" --databases "${MYSQL_DATABASE}" --no-tablespaces 2>/dev/null' \
> "${BACKUP_PATH}/db-dumps/${name}.sql" 2>/dev/null && [ -s "${BACKUP_PATH}/db-dumps/${name}.sql" ]; then
log " OK via MYSQL_USER ($(du -h "${BACKUP_PATH}/db-dumps/${name}.sql" | cut -f1))"
elif docker exec "$id" sh -c \
'mysqldump -u"${MYSQL_USER}" -p"${MYSQL_PASSWORD}" --databases "${MYSQL_DATABASE}" --no-tablespaces 2>/dev/null' \
> "${BACKUP_PATH}/db-dumps/${name}.sql" 2>/dev/null && [ -s "${BACKUP_PATH}/db-dumps/${name}.sql" ]; then
log " OK via mysqldump ($(du -h "${BACKUP_PATH}/db-dumps/${name}.sql" | cut -f1))"
else
log " FAILED - check container logs"
rm -f "${BACKUP_PATH}/db-dumps/${name}.sql"
fi
;;
*postgres*)
log " Dumping PostgreSQL: ${name}"
if docker exec "$id" sh -c \
'pg_dumpall -U "${POSTGRES_USER:-postgres}" 2>/dev/null' \
> "${BACKUP_PATH}/db-dumps/${name}.sql" 2>/dev/null; then
log " OK ($(du -h "${BACKUP_PATH}/db-dumps/${name}.sql" | cut -f1))"
else
log " FAILED - check container logs"
rm -f "${BACKUP_PATH}/db-dumps/${name}.sql"
fi
;;
*influxdb:1*|*influxdb:1.*)
log " Dumping InfluxDB 1.x: ${name}"
docker exec "$id" rm -rf /tmp/influx-backup 2>/dev/null || true
if docker exec "$id" influxd backup -portable /tmp/influx-backup 2>/dev/null; then
docker exec "$id" tar cf - -C /tmp influx-backup \
> "${BACKUP_PATH}/db-dumps/${name}-influx.tar" 2>/dev/null
docker exec "$id" rm -rf /tmp/influx-backup 2>/dev/null || true
log " OK ($(du -h "${BACKUP_PATH}/db-dumps/${name}-influx.tar" | cut -f1))"
else
log " FAILED - check container logs"
fi
;;
*influxdb*)
log " Dumping InfluxDB 2.x: ${name}"
docker exec "$id" rm -rf /tmp/influx-backup 2>/dev/null || true
if docker exec "$id" influx backup /tmp/influx-backup 2>/dev/null; then
docker exec "$id" tar cf - -C /tmp influx-backup \
> "${BACKUP_PATH}/db-dumps/${name}-influx.tar" 2>/dev/null
docker exec "$id" rm -rf /tmp/influx-backup 2>/dev/null || true
log " OK ($(du -h "${BACKUP_PATH}/db-dumps/${name}-influx.tar" | cut -f1))"
else
log " FAILED - check container logs"
fi
;;
esac
done
# ============================================================
# Phase 2: Volume backups
# Backs up ALL named Docker volumes from the host mount.
# Anonymous volumes (64-char hex) are skipped.
# ============================================================
log "Phase 2: Volume backups (auto-discovery)"
volume_count=0
for volume_dir in "${VOLUME_SOURCE}"/*/; do
[ -d "$volume_dir" ] || continue
volume_name=$(basename "$volume_dir")
# Skip internal Docker metadata
[[ "$volume_name" == "backingFsBlockDev" ]] && continue
# Skip anonymous volumes (64-char hex strings)
if [[ ${#volume_name} -eq 64 ]] && [[ "$volume_name" =~ ^[a-f0-9]+$ ]]; then
log " Skipping anonymous volume: ${volume_name:0:12}..."
continue
fi
data_dir="${volume_dir}_data"
if [ ! -d "$data_dir" ]; then
log " Skipping ${volume_name} (no _data directory)"
continue
fi
log " Backing up volume: ${volume_name}"
if tar czf "${BACKUP_PATH}/volumes/${volume_name}.tar.gz" -C "$data_dir" . 2>/dev/null; then
log " OK ($(du -h "${BACKUP_PATH}/volumes/${volume_name}.tar.gz" | cut -f1))"
volume_count=$((volume_count + 1))
else
log " FAILED"
fi
done
# ============================================================
# Phase 2b: Bind mount backups
# These are NOT Docker named volumes — they need explicit paths.
# ============================================================
log "Phase 2b: Bind mount backups"
BIND_MOUNT_SOURCE="${BIND_MOUNT_SOURCE:-/source/bind-mounts}"
mkdir -p "${BACKUP_PATH}/bind-mounts"
bind_count=0
for bind_dir in "${BIND_MOUNT_SOURCE}"/*/; do
[ -d "$bind_dir" ] || continue
bind_name=$(basename "$bind_dir")
log " Backing up bind mount: ${bind_name}"
if tar czf "${BACKUP_PATH}/bind-mounts/${bind_name}.tar.gz" -C "$bind_dir" . 2>/dev/null; then
log " OK ($(du -h "${BACKUP_PATH}/bind-mounts/${bind_name}.tar.gz" | cut -f1))"
bind_count=$((bind_count + 1))
else
log " FAILED"
fi
done
# ============================================================
# Phase 3: Compress database dumps
# ============================================================
log "Phase 3: Compressing database dumps"
for dump in "${BACKUP_PATH}/db-dumps"/*.sql; do
[ -f "$dump" ] || continue
gzip "$dump" 2>/dev/null && log " Compressed: $(basename "$dump").gz" || true
done
# ============================================================
# Phase 4: Upload to Cloudflare R2
# ============================================================
log "Phase 4: Uploading to R2 (${R2_REMOTE}:${R2_BUCKET}/${DATE}/)"
if rclone copy "${BACKUP_PATH}/" "${R2_REMOTE}:${R2_BUCKET}/${DATE}/" \
--transfers 4 \
--retries 3 \
--log-level INFO 2>&1 | while read -r line; do log " rclone: $line"; done; then
log " Upload complete"
else
log " ERROR: Upload to R2 failed!"
curl -fsS --max-time 10 --retry 3 "${HC_BACKUP_URL}/fail" --data-raw "R2 upload failed" > /dev/null 2>&1 || true
exit 1
fi
# ============================================================
# Phase 5: Remote retention cleanup
# Delete old backups from R2 older than RETENTION_DAYS.
# ============================================================
log "Phase 5: Cleaning old backups from R2 (older than ${RETENTION_DAYS} days)"
deleted=0
cutoff_epoch=$(date -d "${RETENTION_DAYS} days ago" +%s 2>/dev/null)
rclone lsd "${R2_REMOTE}:${R2_BUCKET}/" 2>/dev/null | awk '{print $NF}' | while read -r dir_name; do
# Parse date from directory name (YYYY-MM-DD_HH-MM-SS)
dir_date="${dir_name%%_*}"
dir_epoch=$(date -d "$dir_date" +%s 2>/dev/null) || continue
if [ "$dir_epoch" -lt "$cutoff_epoch" ]; then
log " Removing old backup: ${dir_name}"
rclone purge "${R2_REMOTE}:${R2_BUCKET}/${dir_name}/" 2>/dev/null || true
deleted=$((deleted + 1))
fi
done
log " Removed ${deleted} old backup(s) from R2"
# ============================================================
# Summary
# ============================================================
LOCAL_SIZE=$(du -sh "${BACKUP_PATH}" 2>/dev/null | cut -f1)
DUMP_COUNT=$(find "${BACKUP_PATH}/db-dumps" -type f 2>/dev/null | wc -l)
log "=== Backup complete ==="
log " Local size: ${LOCAL_SIZE} (will be cleaned up)"
log " Volumes: ${volume_count}"
log " Bind mounts: ${bind_count}"
log " DB dumps: ${DUMP_COUNT}"
log " Uploaded to: ${R2_REMOTE}:${R2_BUCKET}/${DATE}/"
log " Retention: ${RETENTION_DAYS} days"
# Signal success to Healthchecks.io
curl -fsS --max-time 10 --retry 3 "${HC_BACKUP_URL}" --data-raw "OK: ${volume_count} volumes, ${bind_count} binds, ${DUMP_COUNT} dumps, ${LOCAL_SIZE}" > /dev/null 2>&1 || true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment