Last active
May 21, 2026 07:45
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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