Skip to content

Instantly share code, notes, and snippets.

@Sly777
Created December 16, 2025 00:08
Show Gist options
  • Select an option

  • Save Sly777/b1f2f9163faa3f5be7de5454ecf999be to your computer and use it in GitHub Desktop.

Select an option

Save Sly777/b1f2f9163faa3f5be7de5454ecf999be to your computer and use it in GitHub Desktop.
Update all LXC containers (Debian/Ubuntu/Alpine) on Proxmox 9.x
#!/bin/bash
# Update all LXC containers (Debian/Ubuntu/Alpine)
# Location: /usr/local/bin/update-all-lxc
set -uo pipefail
# Configuration
LOG_DIR="/var/log/lxc-updates"
REPORT_FILE="$LOG_DIR/update-$(date '+%Y%m%d-%H%M%S').log"
LATEST_LINK="$LOG_DIR/latest.log"
MAX_PARALLEL=4
# Colors for terminal output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Containers to always skip (space-separated CTIDs)
SKIP_CONTAINERS="${SKIP_CONTAINERS:-}"
# Default options
DRY_RUN=false
UPGRADE_TYPE="upgrade"
AUTOREMOVE=false
SEQUENTIAL=true # Default to sequential (safer)
INCLUDE_OFFLINE=false
QUIET=false
# Counters
TOTAL=0
UPDATED=0
FAILED=0
SKIPPED=0
STARTED=0
# Arrays for tracking
declare -a FAILED_CTS=()
declare -a UPDATED_CTS=()
declare -a SKIPPED_CTS=()
declare -a STARTED_CTS=()
declare -a REBOOT_REQUIRED_CTS=()
# Ensure log directory exists
mkdir -p "$LOG_DIR"
# Logging functions
log() {
local msg="$1"
echo -e "$msg"
echo -e "$msg" | sed 's/\x1b\[[0-9;]*m//g' >> "$REPORT_FILE"
}
log_only() {
echo -e "$1" | sed 's/\x1b\[[0-9;]*m//g' >> "$REPORT_FILE"
}
print_usage() {
cat << EOF
Usage: $(basename "$0") [OPTIONS]
Update all LXC containers (Debian/Ubuntu/Alpine) on Proxmox.
Options:
-n, --dry-run Show what would be updated without making changes
-d, --dist-upgrade Use dist-upgrade instead of upgrade (Debian/Ubuntu only)
-a, --autoremove Run autoremove and autoclean after upgrade
-p, --parallel Update containers in parallel (default: sequential)
-o, --include-offline Include offline containers (will start them temporarily)
-q, --quiet Minimal terminal output (full details in log)
-h, --help Show this help message
Environment Variables:
SKIP_CONTAINERS Space-separated CTIDs to skip (e.g., '108 117')
Reports:
All reports saved to: $LOG_DIR/
Latest report linked: $LATEST_LINK
Examples:
$(basename "$0") # Update all running containers
$(basename "$0") --dry-run # Preview updates without applying
$(basename "$0") --include-offline # Include stopped containers
$(basename "$0") -p -a # Parallel with cleanup
SKIP_CONTAINERS="108 117" $(basename "$0") # Skip specific containers
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
-n|--dry-run)
DRY_RUN=true
shift
;;
-d|--dist-upgrade)
UPGRADE_TYPE="dist-upgrade"
shift
;;
-a|--autoremove)
AUTOREMOVE=true
shift
;;
-p|--parallel)
SEQUENTIAL=false
shift
;;
-o|--include-offline)
INCLUDE_OFFLINE=true
shift
;;
-q|--quiet)
QUIET=true
shift
;;
-h|--help)
print_usage
exit 0
;;
*)
echo "Unknown option: $1"
print_usage
exit 1
;;
esac
done
}
get_container_name() {
local ctid=$1
pct list | awk -v id="$ctid" '$1==id {print $3}'
}
get_container_status() {
local ctid=$1
pct list | awk -v id="$ctid" '$1==id {print $2}'
}
detect_os_type() {
local ctid=$1
# Check for apt (Debian/Ubuntu)
if pct exec "$ctid" -- which apt-get &>/dev/null 2>&1; then
echo "debian"
return 0
fi
# Check for apk (Alpine)
if pct exec "$ctid" -- which apk &>/dev/null 2>&1; then
echo "alpine"
return 0
fi
# Check for dnf/yum (RHEL/Fedora) - future support
if pct exec "$ctid" -- which dnf &>/dev/null 2>&1; then
echo "rhel"
return 0
fi
echo "unknown"
}
check_reboot_required() {
local ctid=$1
pct exec "$ctid" -- test -f /var/run/reboot-required 2>/dev/null && return 0 || return 1
}
start_container() {
local ctid=$1
local name=$2
log " ${CYAN}Starting container...${NC}"
if pct start "$ctid" &>/dev/null; then
# Wait for container to be ready
local retries=15
while [[ $retries -gt 0 ]]; do
if pct exec "$ctid" -- true &>/dev/null; then
STARTED_CTS+=("$ctid:$name")
((STARTED++))
return 0
fi
sleep 1
((retries--))
done
log " ${RED}Container started but not responding${NC}"
return 1
else
log " ${RED}Failed to start container${NC}"
return 1
fi
}
stop_container() {
local ctid=$1
log " ${CYAN}Stopping container (was offline)...${NC}"
pct stop "$ctid" &>/dev/null || true
}
get_debian_upgradable() {
local ctid=$1
local count
count=$(pct exec "$ctid" -- sh -c "apt list --upgradable 2>/dev/null | grep -v '^Listing' | wc -l" 2>/dev/null || echo "0")
echo "$((count + 0))"
}
get_alpine_upgradable() {
local ctid=$1
local count
count=$(pct exec "$ctid" -- sh -c "apk list -u 2>/dev/null | wc -l" 2>/dev/null || echo "0")
echo "$((count + 0))"
}
update_debian() {
local ctid=$1
local name=$2
local upgradable=0
if $DRY_RUN; then
log " ${YELLOW}[DRY RUN] Would run: apt-get update && apt-get $UPGRADE_TYPE${NC}"
pct exec "$ctid" -- apt-get update -qq &>/dev/null || true
upgradable=$(get_debian_upgradable "$ctid")
if [[ $upgradable -gt 0 ]]; then
log " ${GREEN}$upgradable package(s) available for upgrade:${NC}"
pct exec "$ctid" -- apt list --upgradable 2>/dev/null | grep -v "^Listing" | head -10 | while read -r line; do
log " - $line"
done
local remaining=$((upgradable - 10))
[[ $remaining -gt 0 ]] && log " ... and $remaining more"
else
log " ${GREEN}Already up to date${NC}"
fi
return 0
fi
# Actual update
log " Updating package lists..."
if ! pct exec "$ctid" -- apt-get update -qq &>/dev/null; then
log " ${RED}Failed to update package lists${NC}"
return 1
fi
upgradable=$(get_debian_upgradable "$ctid")
if [[ $upgradable -eq 0 ]]; then
log " ${GREEN}Already up to date${NC}"
UPDATED_CTS+=("$ctid:$name:up-to-date")
return 0
fi
log " Running $UPGRADE_TYPE ($upgradable packages)..."
log_only " Packages to upgrade:"
pct exec "$ctid" -- apt list --upgradable 2>/dev/null | grep -v "^Listing" | while read -r line; do
log_only " - $line"
done
if ! pct exec "$ctid" -- apt-get "$UPGRADE_TYPE" -y -qq &>/dev/null; then
log " ${RED}Failed to upgrade packages${NC}"
return 1
fi
if $AUTOREMOVE; then
log " Cleaning up..."
pct exec "$ctid" -- apt-get autoremove -y -qq &>/dev/null || true
pct exec "$ctid" -- apt-get autoclean -qq &>/dev/null || true
fi
log " ${GREEN}✓ Updated successfully ($upgradable packages)${NC}"
UPDATED_CTS+=("$ctid:$name:$upgradable-packages")
# Check if reboot is required
if check_reboot_required "$ctid"; then
log " ${YELLOW}⚠ Reboot required${NC}"
REBOOT_REQUIRED_CTS+=("$ctid:$name")
fi
return 0
}
update_alpine() {
local ctid=$1
local name=$2
local upgradable=0
if $DRY_RUN; then
log " ${YELLOW}[DRY RUN] Would run: apk update && apk upgrade${NC}"
pct exec "$ctid" -- apk update &>/dev/null || true
upgradable=$(get_alpine_upgradable "$ctid")
if [[ $upgradable -gt 0 ]]; then
log " ${GREEN}$upgradable package(s) available for upgrade:${NC}"
pct exec "$ctid" -- apk list -u 2>/dev/null | head -10 | while read -r line; do
log " - $line"
done
local remaining=$((upgradable - 10))
[[ $remaining -gt 0 ]] && log " ... and $remaining more"
else
log " ${GREEN}Already up to date${NC}"
fi
return 0
fi
# Actual update
log " Updating package index..."
if ! pct exec "$ctid" -- apk update &>/dev/null; then
log " ${RED}Failed to update package index${NC}"
return 1
fi
upgradable=$(get_alpine_upgradable "$ctid")
if [[ $upgradable -eq 0 ]]; then
log " ${GREEN}Already up to date${NC}"
UPDATED_CTS+=("$ctid:$name:up-to-date")
return 0
fi
log " Running apk upgrade ($upgradable packages)..."
log_only " Packages to upgrade:"
pct exec "$ctid" -- apk list -u 2>/dev/null | while read -r line; do
log_only " - $line"
done
if ! pct exec "$ctid" -- apk upgrade --no-cache &>/dev/null; then
log " ${RED}Failed to upgrade packages${NC}"
return 1
fi
if $AUTOREMOVE; then
log " Cleaning up..."
pct exec "$ctid" -- apk cache clean &>/dev/null || true
fi
log " ${GREEN}✓ Updated successfully ($upgradable packages)${NC}"
UPDATED_CTS+=("$ctid:$name:$upgradable-packages")
return 0
}
update_container() {
local ctid=$1
local name
local status
local os_type
local was_offline=false
name=$(get_container_name "$ctid")
status=$(get_container_status "$ctid")
# Check if container should be skipped
if [[ " $SKIP_CONTAINERS " =~ " $ctid " ]]; then
log "${YELLOW}[$ctid] $name - SKIPPED (in skip list)${NC}"
SKIPPED_CTS+=("$ctid:$name:skip-list")
((SKIPPED++))
return 0
fi
# Handle offline containers
if [[ "$status" != "running" ]]; then
if $INCLUDE_OFFLINE; then
log "${BLUE}────────────────────────────────────────${NC}"
log "${BLUE}[$ctid] $name ${YELLOW}(was offline)${NC}"
log "${BLUE}────────────────────────────────────────${NC}"
was_offline=true
if ! $DRY_RUN; then
if ! start_container "$ctid" "$name"; then
FAILED_CTS+=("$ctid:$name:failed-to-start")
((FAILED++))
return 1
fi
else
log " ${YELLOW}Would start container, update, then stop${NC}"
fi
else
if ! $QUIET; then
log "${YELLOW}[$ctid] $name - SKIPPED (offline)${NC}"
fi
log_only "[$ctid] $name - SKIPPED (offline)"
SKIPPED_CTS+=("$ctid:$name:offline")
((SKIPPED++))
return 0
fi
else
log "${BLUE}────────────────────────────────────────${NC}"
log "${BLUE}[$ctid] $name${NC}"
log "${BLUE}────────────────────────────────────────${NC}"
fi
# Detect OS type
os_type=$(detect_os_type "$ctid")
log " ${CYAN}Detected OS: $os_type${NC}"
case "$os_type" in
debian)
if update_debian "$ctid" "$name"; then
((UPDATED++))
else
FAILED_CTS+=("$ctid:$name:update-failed")
((FAILED++))
fi
;;
alpine)
if update_alpine "$ctid" "$name"; then
((UPDATED++))
else
FAILED_CTS+=("$ctid:$name:update-failed")
((FAILED++))
fi
;;
*)
log " ${YELLOW}Unsupported OS type - skipping${NC}"
SKIPPED_CTS+=("$ctid:$name:unsupported-os")
((SKIPPED++))
;;
esac
# Stop container if it was originally offline
# Stop container if it was originally offline
if [[ "$was_offline" == true ]] && ! $DRY_RUN; then
stop_container "$ctid"
fi
return 0
}
print_summary() {
log ""
log "${BLUE}════════════════════════════════════════════════════════════${NC}"
log "${BLUE} SUMMARY${NC}"
log "${BLUE}════════════════════════════════════════════════════════════${NC}"
log ""
log " Run completed: $(date '+%Y-%m-%d %H:%M:%S')"
log " Mode: $( $DRY_RUN && echo 'DRY RUN' || echo 'LIVE' )"
log " Upgrade type: $UPGRADE_TYPE"
log " Include offline: $INCLUDE_OFFLINE"
log " Execution: $( $SEQUENTIAL && echo 'Sequential' || echo 'Parallel' )"
log ""
log " ${CYAN}Total containers: $TOTAL${NC}"
log " ${GREEN}Updated: $UPDATED${NC}"
log " ${YELLOW}Skipped: $SKIPPED${NC}"
log " ${RED}Failed: $FAILED${NC}"
if [[ $STARTED -gt 0 ]]; then
log " ${CYAN}Temporarily started: $STARTED${NC}"
fi
# Detailed breakdown
if [[ ${#UPDATED_CTS[@]} -gt 0 ]]; then
log ""
log " ${GREEN}Updated containers:${NC}"
for entry in "${UPDATED_CTS[@]}"; do
IFS=':' read -r ctid name info <<< "$entry"
log " ✓ [$ctid] $name ${CYAN}($info)${NC}"
done
fi
if [[ ${#SKIPPED_CTS[@]} -gt 0 ]]; then
log ""
log " ${YELLOW}Skipped containers:${NC}"
for entry in "${SKIPPED_CTS[@]}"; do
IFS=':' read -r ctid name reason <<< "$entry"
log " - [$ctid] $name ${CYAN}($reason)${NC}"
done
fi
if [[ ${#FAILED_CTS[@]} -gt 0 ]]; then
log ""
log " ${RED}Failed containers:${NC}"
for entry in "${FAILED_CTS[@]}"; do
IFS=':' read -r ctid name reason <<< "$entry"
log " ✗ [$ctid] $name ${CYAN}($reason)${NC}"
done
fi
if [[ ${#REBOOT_REQUIRED_CTS[@]} -gt 0 ]]; then
log ""
log " ${YELLOW}⚠ Reboot required:${NC}"
for entry in "${REBOOT_REQUIRED_CTS[@]}"; do
IFS=':' read -r ctid name <<< "$entry"
log " ⚠ [$ctid] $name"
done
fi
log ""
log " ${CYAN}Report saved to: $REPORT_FILE${NC}"
log ""
}
cleanup_old_logs() {
# Keep only last 30 reports
local count
count=$(find "$LOG_DIR" -name "update-*.log" -type f 2>/dev/null | wc -l)
if [[ $count -gt 30 ]]; then
find "$LOG_DIR" -name "update-*.log" -type f -printf '%T+ %p\n' 2>/dev/null | \
sort | head -n $((count - 30)) | cut -d' ' -f2- | xargs rm -f 2>/dev/null
fi
}
main() {
parse_args "$@"
# Initialize report file
cat > "$REPORT_FILE" << EOF
================================================================================
LXC Container Update Report
================================================================================
Generated: $(date '+%Y-%m-%d %H:%M:%S')
Hostname: $(hostname)
Mode: $( $DRY_RUN && echo 'DRY RUN' || echo 'LIVE' )
Options: upgrade=$UPGRADE_TYPE autoremove=$AUTOREMOVE sequential=$SEQUENTIAL include-offline=$INCLUDE_OFFLINE
Skip list: ${SKIP_CONTAINERS:-none}
================================================================================
EOF
log "${BLUE}════════════════════════════════════════════════════════════${NC}"
log "${BLUE} LXC Container Update Script${NC}"
log "${BLUE} $(date '+%Y-%m-%d %H:%M:%S')${NC}"
log "${BLUE}════════════════════════════════════════════════════════════${NC}"
log ""
if $DRY_RUN; then
log "${YELLOW}*** DRY RUN MODE - No changes will be made ***${NC}"
log ""
fi
# Get list of LXC containers only (not VMs)
# pct list only shows LXC containers, not VMs
local container_list
if $INCLUDE_OFFLINE; then
container_list=$(pct list 2>/dev/null | awk 'NR>1 {print $1}' | sort -n)
else
container_list=$(pct list 2>/dev/null | awk 'NR>1 && $2=="running" {print $1}' | sort -n)
fi
if [[ -z "$container_list" ]]; then
log "${YELLOW}No LXC containers found.${NC}"
log ""
log "Note: This script only updates LXC containers, not VMs."
exit 0
fi
# Count total containers
TOTAL=$(echo "$container_list" | wc -w)
if $INCLUDE_OFFLINE; then
log "${GREEN}Mode: Including offline containers${NC}"
else
log "${GREEN}Mode: Running containers only${NC}"
fi
log "${GREEN}Found $TOTAL LXC container(s) to process${NC}"
log ""
# Process containers
if $SEQUENTIAL; then
log "${CYAN}Processing sequentially...${NC}"
log ""
for ctid in $container_list; do
update_container "$ctid"
done
else
log "${CYAN}Processing in parallel (max $MAX_PARALLEL concurrent)...${NC}"
log ""
local pids=()
for ctid in $container_list; do
# Start update in background
update_container "$ctid" &
pids+=($!)
# Limit parallel jobs
while [[ ${#pids[@]} -ge $MAX_PARALLEL ]]; do
# Wait for any job to finish
local new_pids=()
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
new_pids+=("$pid")
fi
done
pids=("${new_pids[@]}")
[[ ${#pids[@]} -ge $MAX_PARALLEL ]] && sleep 1
done
done
# Wait for remaining jobs
for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || true
done
fi
# Print summary
print_summary
# Update latest symlink
ln -sf "$REPORT_FILE" "$LATEST_LINK"
# Cleanup old logs
cleanup_old_logs
# Exit with appropriate code
if [[ $FAILED -gt 0 ]]; then
exit 1
fi
exit 0
}
# Run main function
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment