Skip to content

Instantly share code, notes, and snippets.

@Eliastik
Last active March 1, 2026 13:31
Show Gist options
  • Select an option

  • Save Eliastik/1ea6657a77e5af69630df8da86d491ed to your computer and use it in GitHub Desktop.

Select an option

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
#!/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