Update Docker stacks easily

Introduction

Usually, Docker stacks on a system are all located within the same folder, e.g., /opt. If these stacks are properly set up with a docker-compose.yml file, a simple docker compose pull followed by a docker compose up -d will update the stack.

To have this done automatically for many stacks, I wrote the following script.

Code

Put the code below into a file, e.g., docker_update.sh, and make it executable: chmod +x docker_update.sh.

You can run the code in a folder that has subfolders with Docker Compose stacks, and it will run docker compose pull in every folder where a Docker stack is detected. If a new image has been pulled, docker compose up -d will be executed to update the stack. Some logging is also done and is available in the file docker_update.log.

Ensure you have checked the script before running it in your environment!

Ensure your Docker stacks support automatic updates by, for example, defining proper versions of images.

Code of the script
#!/bin/bash

# Configuration
LOG_FILE="docker_update.log"
COMPOSE_FILES=("docker-compose.yml" "docker-compose.yaml" "compose.yml" "compose.yaml")
TIMEOUT=600  # Timeout in seconds for operations

# Function to log messages
log() {
    local message="$1"
    local timestamp=$(date "+%Y-%m-%d %H:%M:%S")
    echo "[${timestamp}] ${message}" | tee -a "$LOG_FILE"
}

# Function to log error messages
log_error() {
    local message="$1"
    log "ERROR: $message"
    echo "ERROR: $message"
}

# Add separator to log file when starting a new run
add_log_separator() {
    echo "" >> "$LOG_FILE"
    echo "=========================================" >> "$LOG_FILE"
    echo "=== Docker Compose Update: $(date) ===" >> "$LOG_FILE"
    echo "=========================================" >> "$LOG_FILE"
    echo "" >> "$LOG_FILE"
}

# Check if required commands are available
check_requirements() {
    for cmd in docker grep timeout; do
        if ! command -v "$cmd" &>/dev/null; then
            log_error "Required command '$cmd' not found"
            exit 1
        fi
    done

    if ! docker info &>/dev/null; then
        log_error "Docker daemon is not running"
        exit 1
    fi
}

# Process a single directory
process_directory() {
    local dir="$1"
    local compose_file=""
    local updated=false
    local base_dir=$(pwd)
    local temp_log=$(mktemp)

    log "Processing directory: $dir"

    # Find compose file
    for file in "${COMPOSE_FILES[@]}"; do
        if [ -f "$dir/$file" ]; then
            compose_file="$file"
            break
        fi
    done

    if [ -z "$compose_file" ]; then
        log "No Docker Compose files found in $dir"
        return 0
    fi

    log "Found $compose_file in $dir"

    # Change to directory
    cd "$dir" || {
        log_error "Could not enter directory $dir"
        return 1
    }

    # Validate compose file
    if ! timeout "$TIMEOUT" docker compose config -q &>/dev/null; then
        log_error "Invalid compose configuration in $dir"
        cd "$base_dir" || true
        return 1
    fi

    # Run docker compose pull and capture output
    log "Running 'docker compose pull' in $dir"
    local pull_output=""
    pull_output=$(timeout "$TIMEOUT" docker compose pull 2>&1)
    local pull_status=$?

    # Log the pull output
    {
        echo "--- Pull output for $dir ---"
        echo "$pull_output"
        echo "--- End pull output ---"
    } >> "$LOG_FILE"

    if [ $pull_status -ne 0 ]; then
        log_error "Failed to pull images in $dir"
        cd "$base_dir" || true
        return 1
    fi

    # Check for image updates by parsing pull output
    local changed=false

    # Method 1: Look for keywords in pull output
    if echo "$pull_output" | grep -qi -E '(Pulling from|Downloaded newer image|Image is up to date|Pull complete)'; then
        # Method 2: Compare image digests before and after
        local before_after_diff=""
        docker compose images > "$temp_log.before" 2>/dev/null

        # Run pull again to ensure we have latest tags
        timeout "$TIMEOUT" docker compose pull > /dev/null 2>&1

        docker compose images > "$temp_log.after" 2>/dev/null

        # Compare before and after image digests
        if ! diff -q "$temp_log.before" "$temp_log.after" > /dev/null 2>&1; then
            changed=true
            log "Image changes detected by diff comparison"
            diff "$temp_log.before" "$temp_log.after" >> "$LOG_FILE" 2>&1
        else
            # Method 3: Force update if pull output contains specific pull indicators
            if echo "$pull_output" | grep -qi -E '(Downloaded newer image|Pulling fs layer|Pull complete)'; then
                changed=true
                log "Image changes detected in pull output"
            fi
        fi
    fi

    # Always try to update if there's any indication of new images
    if [ "$changed" = true ] || echo "$pull_output" | grep -qi -E '(Downloaded newer image|Pull complete)'; then
        log "Changes detected, updating containers in $dir"
        if timeout "$TIMEOUT" docker compose up -d 2>&1 | tee -a "$LOG_FILE"; then
            log "Successfully updated containers in $dir"
            updated=true
        else
            log_error "Failed to update containers in $dir"
            cd "$base_dir" || true
            rm -f "$temp_log.before" "$temp_log.after" "$temp_log"
            return 1
        fi
    else
        log "No changes detected for $dir, skipping update"
    fi

    cd "$base_dir" || true
    rm -f "$temp_log.before" "$temp_log.after" "$temp_log"

    if [ "$updated" = true ]; then
        return 2  # Special return code for updated directories
    else
        return 0  # Success but no update
    fi
}

# Main script
main() {
    check_requirements
    add_log_separator
    log "Starting Docker Compose update process"

    # Find all valid directories
    local directories=()

    # Include current directory if it has compose files
    for file in "${COMPOSE_FILES[@]}"; do
        if [ -f "$file" ]; then
            directories+=(".")
            break
        fi
    done

    # Find subdirectories
    while IFS= read -r -d $'\0' dir; do
        dir=${dir#./}  # Remove leading ./
        if [ "$dir" != "." ] && [ -d "$dir" ]; then
            for file in "${COMPOSE_FILES[@]}"; do
                if [ -f "$dir/$file" ]; then
                    directories+=("$dir")
                    break
                fi
            done
        fi
    done < <(find . -maxdepth 1 -type d -not -path '*/\.*' -print0)

    if [ ${#directories[@]} -eq 0 ]; then
        log "No directories with Docker Compose files found"
        return 0
    fi

    log "Found ${#directories[@]} directories with Docker Compose files to process"
    for dir in "${directories[@]}"; do
        log "- $dir"
    done

    # Process directories
    local total_dirs=0 successful_dirs=0 updated_dirs=0 failed_dirs=0

    for dir in "${directories[@]}"; do
        ((total_dirs++))
        process_directory "$dir"
        case $? in
            0) ((successful_dirs++)) ;;
            2) ((successful_dirs++)) && ((updated_dirs++)) ;;
            *) ((failed_dirs++)) ;;
        esac
    done

    # Generate summary
    log "Summary: Processed $total_dirs directories"
    log "Successfully processed: $successful_dirs"
    log "Services updated: $updated_dirs"
    log "Failed to process: $failed_dirs"
    log "Docker Compose update process completed"
    echo "Process completed. Check $LOG_FILE for details."

    [ $failed_dirs -eq 0 ] || exit 1
}

# Execute main function
main