#!/usr/bin/env bash ############################################################################### ### Docker Stack Backup Script ### Created by Charlie Silverman - ackitsme3@gmail.com ############################################################################### ### Version: 1.1.3 ### Updated: 2026-08-05 ### Purpose: Backs up all docker stacks in a given directory to Restic bucket ############################################################################### ### Requirements: ### Restic environment file: /root/.restic-env ### File format: ### export AWS_ACCESS_KEY_ID="[S3 Bucket Access Key ID]" ### export AWS_SECRET_ACCESS_KEY="[S3 Secret Access Key]" ### export RESTIC_REPOSITORY="Restic repository URL" ### export RESTIC_PASSWORD_FILE="Filepath to text file with restic password" ### Exception list: Optionally store a list of exceptions in the file ### dockerbackup-exception.list in the same directory ### as this file. File should be one entry per line. ### Each entry can contain wildcards if neeeded. ############################################################################### # Version History: # 1.0.0 - Initial build # 1.1.0 - Added exception list via text file # 1.1.1 - Added snapshot size to log # 1.1.2 - Added aggregate backup size to end of log # 1.1.3 - Added GiB to total backup size information set -Eeuo pipefail ############################################################################### # Configuration ############################################################################### COMPOSE_ROOT="/home/ackitsme/docker" LOG_DIR="/home/ackitsme/docker/backuplogs" LOG_FILE="${LOG_DIR}/docker-backup_$(date +%Y%m%d-%H%M%S).log" # Restic repository configuration export RESTIC_REPOSITORY="s3:https://s3.us-west-004.backblazeb2.com/ackitsbucket/docker" export RESTIC_ENV_FILE="/root/.restic-env" # Load Restic environment source $RESTIC_ENV_FILE HOSTNAME_SHORT="$(hostname -s)" TIMESTAMP="$(date +%Y%m%d-%H%M%S)" # Load exceptions, if any if [ -f "dockerbackup-exception.list" ]; then mapfile -t SKIP_PROJECTS < dockerbackup-exception.list fi ############################################################################### # Functions ############################################################################### ## Log to screen and log file log() { local message="[$(date '+%F %T')] $*" echo "$message" echo "$message" >> "$LOG_FILE" } ## Check if specified project is in the exception list should_skip() { local project="$1" for pattern in "${SKIP_PROJECTS[@]}"; do if [[ "$project" == $pattern ]]; then return 0 fi done return 1 } ## Check if docker container is running is_container_running() { local container_name="$1" docker inspect -f '{{.State.Running}}' "$container_name" 2>/dev/null | grep -q '^true$' } ## Restart composer stack restart_stack() { local compose_dir="$1" log "Restarting stack: ${compose_dir}" ( sudo -u ackitsme docker compose up -d su - ackitsme -c "cd ${compose_dir};docker compose up -d" ) || log "WARNING: Failed to restart stack ${compose_dir}" } ############################################################################### # Verify Requirements ############################################################################### for cmd in docker restic tar gzip; do command -v "$cmd" >/dev/null 2>&1 || { echo "Missing required command: $cmd" exit 1 } done ############################################################################### # Locate Compose Files ############################################################################### mapfile -t COMPOSE_FILES < <( find "$COMPOSE_ROOT" \ \( -name "docker-compose.yml" \ -o -name "docker-compose.yaml" \ -o -name "compose.yml" \ -o -name "compose.yaml" \) \ | sort ) if [ "${#COMPOSE_FILES[@]}" -eq 0 ]; then log "No compose files found." exit 0 fi ############################################################################### # Process Each Compose Project ############################################################################### total_size=0 for compose_file in "${COMPOSE_FILES[@]}"; do compose_dir="$(dirname "$compose_file")" project_name="$(basename "$compose_dir")" if should_skip "$project_name"; then log "Skipping project: ${project_name}" continue fi log "==================================================" log "Processing project: ${project_name}" log "Compose file: ${compose_file}" log "==================================================" STAGING_DIR="/tmp/docker-backup-${project_name}-${TIMESTAMP}" mkdir -p \ "${STAGING_DIR}/images" \ "${STAGING_DIR}/volumes" \ "${STAGING_DIR}/metadata" ################################################### # Gather Information Before Shutdown ################################################### cd "$compose_dir" docker compose config --images \ | sort -u \ > "${STAGING_DIR}/metadata/images.list" || true docker compose ps -q \ > "${STAGING_DIR}/metadata/containers.list" || true ################################################### # Determine Volumes Used By Containers ################################################### > "${STAGING_DIR}/metadata/volumes.list" while read -r container_id; do [ -z "$container_id" ] && continue docker inspect "$container_id" \ --format '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}{{println}}{{end}}{{end}}' done < "${STAGING_DIR}/metadata/containers.list" \ | sort -u \ > "${STAGING_DIR}/metadata/volumes.list" ################################################### # Stop Stack ################################################### log "Stopping stack..." docker compose down --timeout 60 ################################################### # Backup Compose Files ################################################### log "Backing up compose files..." tar -czf \ "${STAGING_DIR}/${project_name}-compose.tar.gz" \ -C "$(dirname "$compose_dir")" \ "$(basename "$compose_dir")" ################################################### # Backup Images ################################################### log "Backing up images..." while read -r image; do [ -z "$image" ] && continue if docker image inspect "$image" >/dev/null 2>&1; then safe_name=$(echo "$image" | sed 's#[/:]#_#g') log "Saving image: $image" docker save "$image" \ | gzip \ > "${STAGING_DIR}/images/${safe_name}.tar.gz" fi done < "${STAGING_DIR}/metadata/images.list" ################################################### # Backup Volumes ################################################### log "Backing up volumes..." while read -r volume; do [ -z "$volume" ] && continue log "Saving volume: $volume" docker run --rm \ -v "${volume}:/source:ro" \ -v "${STAGING_DIR}/volumes:/backup" \ alpine \ tar -czf "/backup/${volume}.tar.gz" -C /source . done < "${STAGING_DIR}/metadata/volumes.list" ################################################### # Metadata ################################################### docker image ls \ > "${STAGING_DIR}/metadata/docker-images.txt" docker volume ls \ > "${STAGING_DIR}/metadata/docker-volumes.txt" docker network ls \ > "${STAGING_DIR}/metadata/docker-networks.txt" docker compose config \ > "${STAGING_DIR}/metadata/rendered-compose.yml" || true ################################################### # Restart Stack ################################################### restart_stack "${compose_dir}" ################################################### # Restic Backup ################################################### log "Uploading project backup to Restic..." restic backup \ "${STAGING_DIR}" \ --tag docker \ --tag compose \ --tag "${project_name}" \ --tag "${HOSTNAME_SHORT}" ################################################### # Get backup size ################################################### lastsnap=`restic ls latest` lastsnapid=$(echo "$lastsnap" | awk '{print $2}') snapdata=`restic snapshots $lastsnapid` snapnum=$(echo $snapdata | awk '{print $14}') snapsu=$(echo $snapdata | awk '{print $15}') case "$snapsu" in "TiB") snapnum=$(echo "scale=2;$snapnum * 1024" | bc) ;; "MiB") snapnum=$(echo "scale=2;$snapnum / 1024" | bc) ;; esac snapnum=$(printf "%.2f\n" "$snapnum") ################################################### # Cleanup Local Backup ################################################### rm -rf "${STAGING_DIR}" log "Project complete: ${project_name} ${snapnum} GiB" log "" total_size=$(bc <<< "$total_size + $snapnum") done ############################################################################### # Retention Policy ############################################################################### log "Applying Restic retention policy..." restic forget \ --keep-daily 7 \ --keep-weekly 4 \ --keep-monthly 12 \ --prune log "All compose projects backed up successfully." log "Total Backup Size: $total_size GiB"