Last active
March 1, 2026 13:31
-
-
Save Eliastik/1ea6657a77e5af69630df8da86d491ed to your computer and use it in GitHub Desktop.
Auto update Docker compose containers based on the check-updates-docker script: https://gist.github.com/Eliastik/38e391183c137442403e4dc46d63ed26
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 | |
| # Filename: auto-update-docker-compose.sh | |
| # | |
| # Author: Eliastik ( eliastiksofts.com/contact ) | |
| # Version 1.2.2 (01 march 2026) | |
| # | |
| # Description: Reads the simplified output of check-updates-docker.sh, | |
| # backs up Docker Compose files, updates image versions, and redeploys containers. | |
| # | |
| # Input format (one line per container): | |
| # update <container> <image> <current_version> <new_version> | |
| # | |
| # Usage: | |
| # ./check-updates-docker.sh -s | ./auto-update-docker-compose.sh /path/to/compose-dir [options] | |
| # ./auto-update-docker-compose.sh /path/to/compose-dir -i updates.txt --dry-run | |
| # | |
| # Changelog: | |
| # Version 1.2.2 (01 march 2026): | |
| # - Add retry with backoff when pulling image (fix DNS server misbehaving error) | |
| # Version 1.2.1 (24 february 2026): | |
| # - Fixed issue with image starting with library/... (official Docker image) | |
| # Version 1.2.0 (22 february 2026): | |
| # - Fixed --quiet/--cron mode not sending output in cron context (replaced /dev/tty with saved file descriptor) | |
| # - Fixed docker compose pull pulling all images instead of only the updated service | |
| # Version 1.1.0 (19 february 2026): | |
| # - Added --quiet flag: suppress all output if no updates were applied and no errors occurred | |
| # - Added --no-color flag: disable colored output (useful for email/log readability) | |
| # - Added --cron flag: shorthand for --quiet --no-color, intended for cron job usage | |
| # Version 1.0.0 (18 february 2026): | |
| # - Initial version | |
| set -euo pipefail | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| BOLD='\033[1m' | |
| RESET='\033[0m' | |
| COMPOSE_DIR="" | |
| INPUT_FILE="" | |
| DRY_RUN=false | |
| VERBOSE=false | |
| NO_PULL=false | |
| BACKUP_DIR="" | |
| WAIT_SECONDS=10 | |
| QUIET=false | |
| NO_COLOR=false | |
| CRON=false | |
| TMPOUT="" | |
| ORIG_STDOUT=1 | |
| ORIG_STDERR=2 | |
| usage() { | |
| local name | |
| name=$(basename "$0") | |
| echo -e "${BOLD}Auto-update Docker containers from Docker Compose files${RESET}" | |
| echo | |
| echo "Syntax: $name <compose_dir> [options]" | |
| echo | |
| echo "Arguments:" | |
| echo " <compose_dir> Directory containing Docker Compose files (required)" | |
| echo | |
| echo "Options:" | |
| echo " -i, --input <file> Read update list from file instead of stdin" | |
| echo " -b, --backup-dir <path> Directory for backups (default: <compose_dir>/.backups)" | |
| echo " -w, --wait <seconds> Delay before post-deploy check (default: 10)" | |
| echo " --no-pull Skip 'docker compose pull' before 'up'" | |
| echo " -n, --dry-run Simulate without modifying files or containers" | |
| echo " -v, --verbose Show docker command output" | |
| echo " --quiet Suppress output if no updates were applied and no errors occurred" | |
| echo " --no-color Disable colored output" | |
| echo " --cron Shorthand for --quiet --no-color (useful for cron jobs)" | |
| echo " -h, --help Show this help" | |
| echo | |
| echo "Examples:" | |
| echo " ./check-updates-docker.sh | $name /srv/docker" | |
| echo " ./check-updates-docker.sh | $name /srv/docker --dry-run --verbose" | |
| echo " $name /srv/docker -i updates.txt -w 30" | |
| echo " ./check-updates-docker.sh | $name /srv/docker --cron" | |
| exit 0 | |
| } | |
| log_info() { echo -e "${BLUE}[INFO]${RESET} $*"; } | |
| log_ok() { echo -e "${GREEN}[OK]${RESET} $*"; } | |
| log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } | |
| log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } | |
| log_dry() { echo -e "${YELLOW}[DRY]${RESET} $*"; } | |
| log_verbose() { | |
| if $VERBOSE; then | |
| echo -e " $*" | |
| fi | |
| } | |
| run_compose() { | |
| if $VERBOSE; then | |
| docker compose "$@" | |
| else | |
| local output | |
| if ! output=$(docker compose "$@" 2>&1); then | |
| echo "$output" | while IFS= read -r line; do | |
| log_error " $line" | |
| done | |
| return 1 | |
| fi | |
| fi | |
| } | |
| run_compose_with_retry() { | |
| local retries=3 | |
| local wait=10 | |
| local attempt=1 | |
| while [[ $attempt -le $retries ]]; do | |
| if run_compose "$@"; then | |
| return 0 | |
| fi | |
| log_warn "Command failed (attempt $attempt/$retries), retrying in ${wait}s..." | |
| sleep "$wait" | |
| ((attempt++)) | |
| done | |
| return 1 | |
| } | |
| backup_file() { | |
| local file="$1" | |
| local timestamp | |
| timestamp=$(date '+%Y%m%d%H%M%S') | |
| local bname | |
| bname=$(basename "$file") | |
| local rel_dir | |
| rel_dir=$(dirname "$file" | sed "s|^${COMPOSE_DIR}||" | sed 's|^/||') | |
| local dest_dir="${BACKUP_DIR}/${rel_dir}" | |
| mkdir -p "$dest_dir" | |
| local dest="${dest_dir}/${bname}.${timestamp}.bak" | |
| cp "$file" "$dest" | |
| echo "$dest" | |
| } | |
| update_compose_version() { | |
| local file="$1" image="$2" old_version="$3" new_version="$4" | |
| local image_escaped old_escaped | |
| image_escaped=$(printf '%s' "$image" | sed 's|[/.]|\\&|g') | |
| old_escaped=$(printf '%s' "$old_version" | sed 's|[/.]|\\&|g') | |
| sed -i "s|image:[[:space:]]*${image_escaped}:${old_escaped}|image: ${image}:${new_version}|g" "$file" | |
| } | |
| if [[ $# -ge 1 && "$1" != -* ]]; then | |
| COMPOSE_DIR="$1" | |
| shift | |
| fi | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -i|--input) INPUT_FILE="$2"; shift 2 ;; | |
| -b|--backup-dir) BACKUP_DIR="$2"; shift 2 ;; | |
| -w|--wait) WAIT_SECONDS="$2"; shift 2 ;; | |
| --no-pull) NO_PULL=true; shift ;; | |
| -n|--dry-run) DRY_RUN=true; shift ;; | |
| -v|--verbose) VERBOSE=true; shift ;; | |
| --quiet) QUIET=true; shift ;; | |
| --no-color) NO_COLOR=true; shift ;; | |
| --cron) CRON=true; shift ;; | |
| -h|--help) usage ;; | |
| *) log_error "Unknown argument: $1"; usage ;; | |
| esac | |
| done | |
| if $CRON; then | |
| QUIET=true | |
| NO_COLOR=true | |
| fi | |
| if $NO_COLOR; then | |
| RED=''; GREEN=''; YELLOW=''; BLUE=''; BOLD=''; RESET='' | |
| fi | |
| if [[ -z "$COMPOSE_DIR" ]]; then | |
| log_error "Compose directory is required." | |
| usage | |
| fi | |
| if [[ ! -d "$COMPOSE_DIR" ]]; then | |
| log_error "Directory not found: $COMPOSE_DIR" | |
| exit 1 | |
| fi | |
| COMPOSE_DIR=$(realpath "$COMPOSE_DIR") | |
| if [[ -z "$BACKUP_DIR" ]]; then | |
| BACKUP_DIR="${COMPOSE_DIR}/.backups" | |
| fi | |
| if [[ -z "$INPUT_FILE" && -t 0 ]]; then | |
| log_error "No input detected. Pipe the output of check-updates-docker.sh or use -i <file>." | |
| echo | |
| echo " Example: ./check-updates-docker.sh | $0 /srv/docker" | |
| exit 1 | |
| fi | |
| if $QUIET; then | |
| TMPOUT=$(mktemp) | |
| exec {ORIG_STDOUT}>&1 {ORIG_STDERR}>&2 | |
| exec > "$TMPOUT" 2>&1 | |
| fi | |
| $DRY_RUN && log_warn "Dry-run mode — no files will be modified, no containers redeployed." | |
| echo | |
| log_info "Compose directory : $COMPOSE_DIR" | |
| log_info "Backup directory : $BACKUP_DIR" | |
| $DRY_RUN && log_info "Mode : dry-run" | |
| $NO_PULL && log_info "docker pull : disabled" | |
| $VERBOSE && log_info "Verbose : enabled" | |
| echo -e "--------------------------------------" | |
| updated=0 | |
| skipped=0 | |
| errors=0 | |
| input_source="/dev/stdin" | |
| [[ -n "$INPUT_FILE" ]] && input_source="$INPUT_FILE" | |
| while IFS= read -r line; do | |
| [[ "$line" =~ ^update[[:space:]] ]] || continue | |
| read -r _ container image old_version new_version <<< "$line" | |
| if [[ -z "$container" || -z "$image" || -z "$old_version" || -z "$new_version" ]]; then | |
| log_warn "Malformed line (missing fields), skipping: $line" | |
| ((skipped++)) || true | |
| continue | |
| fi | |
| # Strip "library/" prefix for official images (Docker Hub implicit namespace) | |
| image_short="${image#library/}" | |
| echo | |
| log_info "${BOLD}Container:${RESET} $container | $image:$old_version -> $new_version" | |
| mapfile -t files < <( | |
| find "$COMPOSE_DIR" -type f \( \ | |
| -name "docker-compose.yml" -o \ | |
| -name "docker-compose.yaml" \ | |
| \) -print0 \ | |
| | xargs -0 grep -l "container_name:[[:space:]]*${container}" 2>/dev/null \ | |
| || true | |
| ) | |
| if [[ "${#files[@]}" -eq 0 ]]; then | |
| log_warn "No compose file found for container '$container' — skipping." | |
| ((skipped++)) || true | |
| echo "--------------------------------------" | |
| continue | |
| fi | |
| for file in "${files[@]}"; do | |
| log_verbose "Compose file: $file" | |
| if ! grep -q "${image_short}:${old_version}" "$file" && ! grep -q "${image}:${old_version}" "$file"; then | |
| log_warn "Pattern '${image}:${old_version}' not found in $file — skipping." | |
| log_verbose "Check that the image tag in the compose file matches exactly." | |
| ((skipped++)) || true | |
| continue | |
| fi | |
| # Determine which image name form is used in the compose file | |
| # Check full name first to avoid false match of short name as substring of full name | |
| if grep -q "${image}:${old_version}" "$file"; then | |
| image_in_file="$image" | |
| elif grep -q "${image_short}:${old_version}" "$file"; then | |
| image_in_file="$image_short" | |
| fi | |
| if $DRY_RUN; then | |
| log_dry "Would backup : $file -> ${BACKUP_DIR}/..." | |
| log_dry "Would replace : ${image_in_file}:${old_version} -> ${image_in_file}:${new_version}" | |
| $NO_PULL || log_dry "Would run : docker compose -f $file pull" | |
| log_dry "Would run : docker compose -f $file up -d <service_name> (resolved from container: $container)" | |
| ((updated++)) || true | |
| continue | |
| fi | |
| backup_path=$(backup_file "$file") | |
| log_verbose "Backup created: $backup_path" | |
| update_compose_version "$file" "$image_in_file" "$old_version" "$new_version" | |
| if ! grep -q "${image_in_file}:${new_version}" "$file"; then | |
| log_error "Replacement failed in $file — restoring backup." | |
| cp "$backup_path" "$file" | |
| ((errors++)) || true | |
| echo "--------------------------------------" | |
| continue | |
| fi | |
| log_ok "Compose file updated: ${image_in_file}:${old_version} -> ${image_in_file}:${new_version}" | |
| service_name=$(docker inspect "$container" --format '{{ index .Config.Labels "com.docker.compose.service" }}' 2>/dev/null || true) | |
| if [[ -z "$service_name" ]]; then | |
| log_error "Could not resolve service name for container '$container' — skipping." | |
| cp "$backup_path" "$file" | |
| ((errors++)) || true | |
| echo "--------------------------------------" | |
| continue | |
| fi | |
| log_verbose "Service name: $service_name" | |
| if ! $NO_PULL; then | |
| log_info "Pulling new image..." | |
| if ! run_compose_with_retry -f "$file" pull "$service_name"; then | |
| log_error "Pull failed — restoring backup." | |
| cp "$backup_path" "$file" | |
| ((errors++)) || true | |
| echo "--------------------------------------" | |
| continue | |
| fi | |
| log_ok "Image pulled." | |
| fi | |
| log_info "Recreating service '$service_name' (container: $container)..." | |
| if ! run_compose -f "$file" up -d "$service_name"; then | |
| log_error "Deployment failed — rolling back." | |
| cp "$backup_path" "$file" | |
| run_compose -f "$file" up -d "$service_name" || true | |
| ((errors++)) || true | |
| echo "--------------------------------------" | |
| continue | |
| fi | |
| log_info "Waiting ${WAIT_SECONDS}s before verification..." | |
| sleep "$WAIT_SECONDS" | |
| if ! docker compose -f "$file" ps --status running --quiet "$service_name" | grep -q .; then | |
| log_error "Container '$container' is not running — rolling back." | |
| cp "$backup_path" "$file" | |
| run_compose -f "$file" up -d "$service_name" || true | |
| ((errors++)) || true | |
| echo "--------------------------------------" | |
| continue | |
| fi | |
| log_ok "Container '$container' is running. Update successful." | |
| ((updated++)) || true | |
| echo "--------------------------------------" | |
| done | |
| done < "$input_source" | |
| echo | |
| echo -e "${BOLD}=====================================${RESET}" | |
| echo -e "${BOLD} Summary${RESET}" | |
| echo -e "${BOLD}=====================================${RESET}" | |
| echo -e " ${GREEN}Updated :${RESET} $updated" | |
| echo -e " ${YELLOW}Skipped :${RESET} $skipped" | |
| echo -e " ${RED}Errors :${RESET} $errors" | |
| echo -e "${BOLD}=====================================${RESET}" | |
| echo | |
| if $QUIET && [[ -n "$TMPOUT" ]]; then | |
| exec >&${ORIG_STDOUT} 2>&${ORIG_STDERR} | |
| if [[ $updated -gt 0 || $errors -gt 0 ]]; then | |
| cat "$TMPOUT" | |
| fi | |
| rm -f "$TMPOUT" | |
| fi | |
| [[ $errors -gt 0 ]] && exit 1 || exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment