#!/bin/bash ############################################################# #### Docker Volume Backup Script #### #### Backup and restore Docker named volumes using #### #### tar archives with optional compression #### #### #### #### Author: Phil Connor #### #### Contact: contact@mylinux.work #### #### License: MIT #### #### Version: 1.0 #### #### #### #### Usage: ./docker-volume-backup.sh [OPTIONS] #### ############################################################# set -euo pipefail SCRIPT_NAME=$(basename "$0") readonly SCRIPT_NAME readonly DEFAULT_BACKUP_DIR="/opt/docker-backups" readonly DEFAULT_RETAIN=7 readonly ALPINE_IMAGE="alpine:latest" BACKUP_DIR="$DEFAULT_BACKUP_DIR" RETAIN="$DEFAULT_RETAIN" MODE="" TARGET_VOLUME="" RESTORE_ARCHIVE="" # Colors readonly RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*"; } log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*" >&2; } log_step() { echo -e "${BLUE}[STEP]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*"; } show_help() { cat << EOF Usage: $SCRIPT_NAME [OPTIONS] Backup and restore Docker named volumes using tar archives with compression. OPTIONS: --backup [VOLUME] Backup all named volumes, or a specific volume if given --restore ARCHIVE Restore a volume from the specified tar.gz archive --list List available backups --backup-dir PATH Backup directory (default: $DEFAULT_BACKUP_DIR) --retain N Number of backups to keep per volume (default: $DEFAULT_RETAIN) --help, -h Show this help message EXAMPLES: $SCRIPT_NAME --backup $SCRIPT_NAME --backup my_volume $SCRIPT_NAME --restore $DEFAULT_BACKUP_DIR/my_volume_20260309_143022.tar.gz $SCRIPT_NAME --list $SCRIPT_NAME --backup --backup-dir /mnt/backups --retain 14 EOF exit 0 } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --backup) MODE="backup"; shift [[ $# -gt 0 && ! "$1" =~ ^-- ]] && { TARGET_VOLUME="$1"; shift; } ;; --restore) MODE="restore" [[ $# -lt 2 ]] && { log_error "--restore requires an archive path"; exit 1; } RESTORE_ARCHIVE="$2"; shift 2 ;; --list) MODE="list"; shift ;; --backup-dir) [[ $# -lt 2 ]] && { log_error "--backup-dir requires a path"; exit 1; } BACKUP_DIR="$2"; shift 2 ;; --retain) [[ $# -lt 2 ]] && { log_error "--retain requires a number"; exit 1; } RETAIN="$2"; shift 2 ;; --help|-h) show_help ;; *) log_error "Unknown option: $1"; show_help ;; esac done if [[ -z "$MODE" ]]; then log_error "No action specified. Use --backup, --restore, or --list." show_help fi } check_dependencies() { if ! command -v docker &>/dev/null; then log_error "docker is required but not installed"; exit 1 fi if ! docker info &>/dev/null; then log_error "Cannot connect to Docker daemon. Is it running?"; exit 1 fi } backup_volume() { local volume_name="$1" local timestamp archive_name final_path tmp_file size timestamp=$(date +%Y%m%d_%H%M%S) archive_name="${volume_name}_${timestamp}.tar.gz" final_path="${BACKUP_DIR}/${archive_name}" log_step "Backing up volume: ${volume_name}" if ! docker volume inspect "$volume_name" &>/dev/null; then log_error "Volume '$volume_name' does not exist"; return 1 fi mkdir -p "$BACKUP_DIR" tmp_file=$(mktemp "${BACKUP_DIR}/.backup_XXXXXX.tar.gz") if docker run --rm \ -v "${volume_name}:/source:ro" \ -v "${BACKUP_DIR}:/backup" \ "$ALPINE_IMAGE" \ tar czf "/backup/$(basename "$tmp_file")" -C /source . 2>/dev/null; then mv "$tmp_file" "$final_path" size=$(du -h "$final_path" | cut -f1) log_info "Created backup: ${final_path} (${size})" else rm -f "$tmp_file" log_error "Failed to backup volume: ${volume_name}"; return 1 fi } do_backup() { log_step "Starting Docker volume backup" log_info "Backup directory: ${BACKUP_DIR}" local volumes=() if [[ -n "$TARGET_VOLUME" ]]; then volumes=("$TARGET_VOLUME") else while IFS= read -r vol; do [[ -n "$vol" ]] && volumes+=("$vol") done < <(docker volume ls --format '{{.Name}}' | sort) fi if [[ ${#volumes[@]} -eq 0 ]]; then log_warn "No Docker named volumes found"; return 0 fi log_info "Found ${#volumes[@]} volume(s) to backup" local success=0 failed=0 for vol in "${volumes[@]}"; do if backup_volume "$vol"; then success=$((success + 1)) else failed=$((failed + 1)) fi done apply_retention log_step "Backup complete: ${success} succeeded, ${failed} failed" [[ $failed -gt 0 ]] && return 1 return 0 } do_restore() { if [[ ! -f "$RESTORE_ARCHIVE" ]]; then log_error "Archive not found: ${RESTORE_ARCHIVE}"; exit 1 fi local basename_archive volume_name basename_archive=$(basename "$RESTORE_ARCHIVE") volume_name=$(echo "$basename_archive" | sed 's/_[0-9]\{8\}_[0-9]\{6\}\.tar\.gz$//') if [[ -z "$volume_name" || "$volume_name" == "$basename_archive" ]]; then log_error "Cannot determine volume name from archive: ${basename_archive}" log_error "Expected format: volumename_YYYYMMDD_HHMMSS.tar.gz"; exit 1 fi log_step "Restoring volume: ${volume_name} from ${RESTORE_ARCHIVE}" if ! docker volume inspect "$volume_name" &>/dev/null; then log_info "Creating volume: ${volume_name}" docker volume create "$volume_name" >/dev/null else log_warn "Volume '${volume_name}' already exists — contents will be overwritten" fi local archive_abs archive_dir archive_file archive_abs=$(realpath "$RESTORE_ARCHIVE") archive_dir=$(dirname "$archive_abs") archive_file=$(basename "$archive_abs") if docker run --rm \ -v "${volume_name}:/target" \ -v "${archive_dir}:/backup:ro" \ "$ALPINE_IMAGE" \ sh -c "rm -rf /target/* /target/..?* /target/.[!.]* 2>/dev/null; tar xzf /backup/${archive_file} -C /target" 2>/dev/null; then log_info "Volume '${volume_name}' restored successfully" else log_error "Failed to restore volume: ${volume_name}"; exit 1 fi } do_list() { if [[ ! -d "$BACKUP_DIR" ]]; then log_info "No backups found (directory does not exist: ${BACKUP_DIR})"; return 0 fi local count=0 log_step "Available backups in ${BACKUP_DIR}:" printf "\n %-40s %-10s %s\n" "ARCHIVE" "SIZE" "VOLUME" printf " %-40s %-10s %s\n" "-------" "----" "------" for archive in "$BACKUP_DIR"/*.tar.gz; do [[ -f "$archive" ]] || continue count=$((count + 1)) local name size vol_name name=$(basename "$archive") size=$(du -h "$archive" | cut -f1) vol_name=$(echo "$name" | sed 's/_[0-9]\{8\}_[0-9]\{6\}\.tar\.gz$//') printf " %-40s %-10s %s\n" "$name" "$size" "$vol_name" done echo "" if [[ $count -eq 0 ]]; then log_info "No backup archives found in ${BACKUP_DIR}" else log_info "${count} backup(s) found" fi } apply_retention() { log_step "Applying retention policy (keep last ${RETAIN} per volume)" local vol_names=() for archive in "$BACKUP_DIR"/*.tar.gz; do [[ -f "$archive" ]] || continue local name vol_name name=$(basename "$archive") vol_name=$(echo "$name" | sed 's/_[0-9]\{8\}_[0-9]\{6\}\.tar\.gz$//') vol_names+=("$vol_name") done local unique_vols unique_vols=$(printf '%s\n' "${vol_names[@]}" 2>/dev/null | sort -u) while IFS= read -r vol; do [[ -z "$vol" ]] && continue local old_archives=() while IFS= read -r f; do [[ -n "$f" ]] && old_archives+=("$f") done < <(ls -1t "$BACKUP_DIR"/${vol}_[0-9]*_[0-9]*.tar.gz 2>/dev/null | tail -n +$((RETAIN + 1))) for old_archive in "${old_archives[@]}"; do log_info "Removing old backup: $(basename "$old_archive")" rm -f "$old_archive" done done <<< "$unique_vols" } main() { parse_args "$@" check_dependencies case "$MODE" in backup) do_backup ;; restore) do_restore ;; list) do_list ;; esac } main "$@"