Files
valknar 8ae9c9e878 fix(update): compare local image store IDs, not running container IDs
docker compose images -q reports the image IDs of currently running
containers, which don't change after a pull — so before == after always
and containers were never recreated.

Fix: resolve each service's image tag to its local SHA256 ID via
docker image inspect, which reads the local image store and correctly
reflects the newly pulled image. Falls back from 'config --images'
(compose v2.19+) to parsing 'config' yaml for older versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:09:56 +02:00

1357 lines
44 KiB
Bash
Executable File

#!/usr/bin/env bash
# stacks.sh — Docker Compose stack manager
set -euo pipefail
# ─── Bootstrap ────────────────────────────────────────────────────────────────
SCRIPT_PATH="$(realpath "${BASH_SOURCE[0]}")"
SCRIPT_NAME="$(basename "$SCRIPT_PATH")"
STACKS_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
ROOT_ENV="$STACKS_DIR/.env"
VERSION="1.0.0"
# ─── Colors ───────────────────────────────────────────────────────────────────
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
BOLD=$'\e[1m' DIM=$'\e[2m'
RED=$'\e[31m' GREEN=$'\e[32m' YELLOW=$'\e[33m'
BLUE=$'\e[34m' MAGENTA=$'\e[35m' CYAN=$'\e[36m'
RESET=$'\e[0m'
else
BOLD='' DIM='' RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' RESET=''
fi
# ─── Output ───────────────────────────────────────────────────────────────────
log() { [[ $QUIET == true ]] && return; echo -e "${BOLD}[stacks]${RESET} $*"; }
info() { [[ $QUIET == true ]] && return; echo -e "${CYAN}${RESET} $*"; }
ok() { [[ $QUIET == true ]] && return; echo -e "${GREEN}${RESET} $*"; }
warn() { echo -e "${YELLOW}${RESET} $*" >&2; }
err() { echo -e "${RED}${RESET} $*" >&2; }
die() { err "$*"; exit 1; }
header() { [[ $QUIET == true ]] && return; echo -e "\n${BOLD}${BLUE}━━━ $* ━━━${RESET}"; }
sep() { [[ $QUIET == true ]] && return; echo -e "${DIM}────────────────────────────────────────${RESET}"; }
# ─── Root env + Notifications ────────────────────────────────────────────────
load_root_env() {
if [[ -f "$ROOT_ENV" ]]; then
# shellcheck disable=SC1090
set -a; source "$ROOT_ENV"; set +a
else
warn "No root .env found at $ROOT_ENV — create it from .env.example"
fi
}
_notify() {
local color="$1" text="$2"
local webhook="${WEBHOOK_URL:-}"
if [[ -z "$webhook" ]]; then
warn "WEBHOOK_URL not set — skipping notification"
return 0
fi
curl -sf -o /dev/null -X POST "$webhook" \
-H 'Content-Type: application/json' \
-d "{\"message\":\"$text\",\"color\":\"$color\"}" \
|| warn "Webhook notification failed"
}
# ─── Global Flags ─────────────────────────────────────────────────────────────
DRY_RUN=false
PARALLEL=false
VERBOSE=false
QUIET=false
# ─── Stack Discovery ──────────────────────────────────────────────────────────
list_all_stacks() {
local stacks=()
for d in "$STACKS_DIR"/*/; do
[[ ! -d "$d" ]] && continue
local name
name="$(basename "$d")"
[[ "$name" == _* ]] && continue
[[ ! -f "$d/compose.yml" ]] && continue
stacks+=("$name")
done
[[ ${#stacks[@]} -gt 0 ]] && printf '%s\n' "${stacks[@]}"
}
# Resolve FILTER args to a de-duplicated list of stack names.
# Supports exact names, glob patterns (*git*, g*), and comma-separated lists.
# Prints one stack name per line; dies if nothing matches.
match_stacks() {
local -a patterns=("$@")
local -a all_stacks=()
mapfile -t all_stacks < <(list_all_stacks)
if [[ ${#patterns[@]} -eq 0 ]]; then
printf '%s\n' "${all_stacks[@]}"
return
fi
local -A seen=()
local -a matched=()
for pattern in "${patterns[@]}"; do
IFS=',' read -ra parts <<< "$pattern"
for part in "${parts[@]}"; do
part="${part// /}"
[[ -z "$part" ]] && continue
local found=false
for stack in "${all_stacks[@]}"; do
# shellcheck disable=SC2254 # intentional glob in [[ ]]
if [[ "$stack" == $part ]]; then
if [[ -z "${seen[$stack]+x}" ]]; then
seen[$stack]=1
matched+=("$stack")
fi
found=true
fi
done
$found || warn "No stack matches pattern: ${BOLD}$part${RESET}"
done
done
[[ ${#matched[@]} -eq 0 ]] && die "No matching stacks found"
printf '%s\n' "${matched[@]}"
}
# ─── Compose Runner ───────────────────────────────────────────────────────────
run_compose() {
local stack="$1"; shift
local stack_dir="$STACKS_DIR/$stack"
[[ ! -d "$stack_dir" ]] && { err "Stack directory not found: $stack"; return 1; }
[[ ! -f "$stack_dir/compose.yml" ]] && { err "No compose.yml in: $stack"; return 1; }
local -a env_flag=()
[[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env)
if $VERBOSE; then
info "${BOLD}$stack${RESET}: docker compose ${env_flag[*]} $*"
fi
if $DRY_RUN; then
echo -e " ${DIM}[dry-run]${RESET} ${CYAN}$stack${RESET}: docker compose ${env_flag[*]} $*"
return 0
fi
(cd "$stack_dir" && docker compose "${env_flag[@]}" "$@")
}
# Run a compose command across multiple stacks, optionally in parallel.
# Usage: run_for_stacks stacks_array_ref COMPOSE_CMD...
run_for_stacks() {
local -n _stacks="$1"; shift
local -a compose_cmd=("$@")
local -i errors=0
if $PARALLEL && [[ ${#_stacks[@]} -gt 1 ]]; then
log "Running in parallel: ${BOLD}${compose_cmd[*]}${RESET} on ${#_stacks[@]} stacks"
local -a pids=()
local -a names=()
for stack in "${_stacks[@]}"; do
run_compose "$stack" "${compose_cmd[@]}" &
pids+=($!)
names+=("$stack")
done
for i in "${!pids[@]}"; do
if ! wait "${pids[$i]}"; then
err "Failed: ${names[$i]}"
((errors++)) || true
fi
done
else
for stack in "${_stacks[@]}"; do
sep
if ! run_compose "$stack" "${compose_cmd[@]}"; then
((errors++)) || true
fi
done
sep
fi
return $errors
}
# ─── Stack Commands ───────────────────────────────────────────────────────────
cmd_ls() {
local -a all_stacks=()
mapfile -t all_stacks < <(list_all_stacks)
if [[ ${#all_stacks[@]} -eq 0 ]]; then
warn "No stacks found in $STACKS_DIR"
return
fi
printf "\n${BOLD}%-18s %-10s %-12s %s${RESET}\n" "STACK" "STATUS" "CONTAINERS" "ENV"
sep
for stack in "${all_stacks[@]}"; do
local stack_dir="$STACKS_DIR/$stack"
local env_label has_env
if [[ -f "$stack_dir/.env" ]]; then
env_label="${GREEN}✓ .env${RESET}"
has_env=true
else
env_label="${YELLOW}✗ .env${RESET}"
has_env=false
fi
local running="0" total="0"
{ running=$(docker ps --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || running="?"
{ total=$( docker ps -a --filter "label=com.docker.compose.project=$stack" -q 2>/dev/null | wc -l); } 2>/dev/null || total="?"
running="${running// /}"
total="${total// /}"
local status status_color
if [[ "$running" == "?" || "$total" == "?" ]]; then
status="${DIM}unknown${RESET}"
status_color="$DIM"
elif [[ "$total" -eq 0 ]]; then
status="${DIM}stopped${RESET}"
status_color="$DIM"
elif [[ "$running" -eq "$total" ]]; then
status="${GREEN}running${RESET}"
status_color="$GREEN"
elif [[ "$running" -eq 0 ]]; then
status="${RED}down${RESET}"
status_color="$RED"
else
status="${YELLOW}partial${RESET}"
status_color="$YELLOW"
fi
printf " ${BOLD}%-16s${RESET} %-10b ${status_color}%s/%s${RESET}%-7s %b\n" \
"$stack" "$status" "$running" "$total" "" "$env_label"
done
echo
}
cmd_ps() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
for stack in "${stacks[@]}"; do
header "$stack"
run_compose "$stack" ps --format table || true
done
}
cmd_up() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
log "Starting ${#stacks[@]} stack(s): ${stacks[*]}"
run_for_stacks stacks up -d
}
cmd_down() {
local remove_volumes=false
local -a filters=()
for arg in "$@"; do
[[ "$arg" == "--volumes" || "$arg" == "-v" ]] && { remove_volumes=true; continue; }
filters+=("$arg")
done
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
log "Stopping ${#stacks[@]} stack(s): ${stacks[*]}"
if $remove_volumes; then
warn "Removing volumes too (--volumes)"
run_for_stacks stacks down --volumes
else
run_for_stacks stacks down
fi
}
cmd_restart() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
log "Restarting ${#stacks[@]} stack(s): ${stacks[*]}"
run_for_stacks stacks restart
}
cmd_pull() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
log "Pulling images for ${#stacks[@]} stack(s): ${stacks[*]}"
run_for_stacks stacks pull
}
cmd_logs() {
local -a dc_flags=()
local -a filters=()
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--follow|-t|--timestamps|--no-color) dc_flags+=("$1"); shift ;;
-n|--tail) dc_flags+=("$1" "${2:?--tail requires a value}"); shift 2 ;;
--tail=*) dc_flags+=("$1"); shift ;;
--since|--until) dc_flags+=("$1" "${2:?$1 requires a value}"); shift 2 ;;
-*) dc_flags+=("$1"); shift ;;
*) filters+=("$1"); shift ;;
esac
done
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
for stack in "${stacks[@]}"; do
[[ ${#stacks[@]} -gt 1 ]] && header "$stack"
run_compose "$stack" logs "${dc_flags[@]}" || true
done
}
cmd_exec() {
[[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME exec STACK SERVICE CMD [ARGS...]"
local stack="$1" service="$2"; shift 2
run_compose "$stack" exec "$service" "$@"
}
cmd_run_svc() {
[[ $# -lt 3 ]] && die "Usage: $SCRIPT_NAME run STACK SERVICE CMD [ARGS...]"
local stack="$1" service="$2"; shift 2
run_compose "$stack" run --rm "$service" "$@"
}
cmd_config() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
for stack in "${stacks[@]}"; do
header "$stack"
run_compose "$stack" config
done
}
cmd_images() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
for stack in "${stacks[@]}"; do
header "$stack"
run_compose "$stack" images
done
}
cmd_top() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
for stack in "${stacks[@]}"; do
header "$stack"
run_compose "$stack" top || true
done
}
cmd_stats() {
local -a filters=("$@")
local -a stacks=()
mapfile -t stacks < <(match_stacks "${filters[@]}")
local -a containers=()
for stack in "${stacks[@]}"; do
local -a running=()
mapfile -t running < <(
docker ps --filter "label=com.docker.compose.project=$stack" --format '{{.Names}}' 2>/dev/null
)
containers+=("${running[@]+"${running[@]}"}")
done
if [[ ${#containers[@]} -eq 0 ]]; then
warn "No running containers for the selected stacks"
return
fi
docker stats "${containers[@]}"
}
# Passthrough: run any docker compose subcommand directly on a stack
cmd_compose() {
[[ $# -lt 1 ]] && die "Usage: $SCRIPT_NAME compose STACK [CMD...]"
local stack="$1"; shift
run_compose "$stack" "$@"
}
# ─── Update Service ───────────────────────────────────────────────────────────
# Resolve each service's image tag to its current local SHA256 ID.
# Reads from the local image store (updated by 'docker compose pull'),
# NOT from running containers — so the comparison reflects the pulled image.
_stack_image_ids() {
local stack_dir="$1"; shift
local -a env_flag=("$@")
local images
# 'config --images' (compose v2.19+) outputs one image name per line;
# fall back to parsing the resolved config yaml for older versions.
if images=$(cd "$stack_dir" && docker compose "${env_flag[@]}" config --images 2>/dev/null) \
&& [[ -n "$images" ]]; then
echo "$images"
else
cd "$stack_dir" && docker compose "${env_flag[@]}" config 2>/dev/null \
| awk '/^\s+image:/{print $2}' | sort -u
fi | xargs -r docker image inspect --format '{{.Id}}' 2>/dev/null | sort
}
_update_run() {
load_root_env
local -a all_stacks=()
mapfile -t all_stacks < <(list_all_stacks)
local -a updated=() failed=()
local -i checked=0
log "Starting image update check (${#all_stacks[@]} stacks)"
for stack in "${all_stacks[@]}"; do
local stack_dir="$STACKS_DIR/$stack"
local -a env_flag=()
[[ -f "$stack_dir/.env" ]] && env_flag=(--env-file .env)
info "Checking ${BOLD}$stack${RESET}"
((checked++)) || true
local before after
before=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}")
(cd "$stack_dir" && docker compose "${env_flag[@]}" pull 2>&1) || true
after=$(_stack_image_ids "$stack_dir" "${env_flag[@]+"${env_flag[@]}"}")
if [[ "$before" != "$after" ]]; then
info " Updates found — recreating $stack"
if (cd "$stack_dir" && docker compose "${env_flag[@]}" up -d 2>&1); then
updated+=("$stack")
ok " $stack updated"
else
err " Failed to recreate $stack"
failed+=("$stack")
fi
else
info " Up to date"
fi
done
log "Pruning dangling images"
docker image prune -f
local date_str
date_str="$(date '+%Y-%m-%d %H:%M')"
if [[ ${#failed[@]} -gt 0 ]]; then
_notify "#cc0000" "❌ **Update failed** — $date_str\nUpdated: ${updated[*]:-none}\nFailed: ${failed[*]}"
return 1
elif [[ ${#updated[@]} -gt 0 ]]; then
_notify "#36a64f" "✅ **Updates applied** — $date_str\nStacks updated (${#updated[@]}/$checked): ${updated[*]}"
else
_notify "#36a64f" "✅ **All up to date** — $date_str ($checked stacks checked)"
fi
log "Update check complete"
}
cmd_update() {
local sub="${1:-help}"; shift || true
case "$sub" in
install)
log "Installing update service"
if $DRY_RUN; then
info "[dry-run] Would write /etc/systemd/system/stacks-update.{service,timer}"
info "[dry-run] systemctl enable --now stacks-update.timer"
return
fi
cat > /etc/systemd/system/stacks-update.service << EOF
[Unit]
Description=Docker stacks image update
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
User=root
ExecStart=${SCRIPT_PATH} update run
Environment=HOME=/root
EOF
cat > /etc/systemd/system/stacks-update.timer << 'EOF'
[Unit]
Description=Daily Docker stacks image update at 2:00 AM
[Timer]
OnCalendar=*-*-* 02:00:00 Europe/Amsterdam
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now stacks-update.timer
ok "Update service installed and enabled"
systemctl status stacks-update.timer --no-pager || true
;;
uninstall)
log "Uninstalling update service"
if $DRY_RUN; then info "[dry-run] Would uninstall update service"; return; fi
systemctl disable --now stacks-update.timer 2>/dev/null || true
rm -f /etc/systemd/system/stacks-update.{service,timer}
systemctl daemon-reload
ok "Update service removed"
;;
run)
log "Running update now"
if $DRY_RUN; then info "[dry-run] Would run image update across all stacks"; return; fi
_update_run
;;
status)
systemctl status stacks-update.timer stacks-update.service --no-pager 2>/dev/null \
|| warn "Update service not installed"
;;
logs)
journalctl -u stacks-update.service "${@}" --no-pager 2>/dev/null \
|| warn "No update logs found (is the service installed?)"
;;
next)
systemctl list-timers stacks-update.timer --no-pager 2>/dev/null \
|| warn "Timer not active"
;;
help|"")
echo "Usage: $SCRIPT_NAME update <subcommand>"
echo "Subcommands: install, uninstall, run, status, logs, next"
;;
*)
die "Unknown update subcommand: $sub (install|uninstall|run|status|logs|next)"
;;
esac
}
# ─── Backup Service ───────────────────────────────────────────────────────────
_backup_run() {
load_root_env
[[ -n "${RESTIC_REPOSITORY:-}" ]] || die "RESTIC_REPOSITORY not set in $ROOT_ENV"
[[ -n "${RESTIC_PASSWORD:-}" ]] || die "RESTIC_PASSWORD not set in $ROOT_ENV"
export RESTIC_REPOSITORY RESTIC_PASSWORD
local data_dir="$STACKS_DIR/.data"
local dump_dir="$data_dir/backup/dumps"
log "Starting backup"
mkdir -p "$dump_dir"
# --- Postgres dumps: detect <stack>_db containers by convention ---
local -a all_stacks=()
mapfile -t all_stacks < <(list_all_stacks)
local -a dump_errors=()
local -i dumped=0
for stack in "${all_stacks[@]}"; do
local container="${stack}_db"
# Check the container exists and is running
if docker ps --filter "name=^/${container}$" --filter "status=running" -q 2>/dev/null | grep -q .; then
info "Dumping ${BOLD}$stack${RESET} DB ($container, user: $stack, db: $stack)"
if docker exec "$container" pg_dump -U "$stack" "$stack" \
> "$dump_dir/${stack}.sql" 2>/dev/null; then
local size
size="$(du -h "$dump_dir/${stack}.sql" 2>/dev/null | cut -f1)"
ok " $stack DB dumped ($size)"
((dumped++)) || true
else
err " Dump failed: $container"
dump_errors+=("$stack")
rm -f "$dump_dir/${stack}.sql"
fi
fi
done
[[ $dumped -gt 0 || ${#dump_errors[@]} -gt 0 ]] \
&& info "DB dumps: $dumped ok, ${#dump_errors[@]} failed"
# --- Restic backup ---
log "Running restic backup of $data_dir"
if ! restic backup "$data_dir" --tag stacks 2>&1; then
err "FATAL: restic backup failed"
_notify "#cc0000" "❌ **Backup failed** — restic error. Check \`journalctl -u stacks-backup\`."
return 1
fi
# --- Prune ---
log "Pruning old snapshots"
restic forget --prune --keep-daily 7 --keep-weekly 4 --keep-monthly 6 2>&1 \
|| warn "restic forget failed"
# --- Summary ---
local snapshot_info repo_stats summary
snapshot_info="$(restic snapshots --latest 1 --compact 2>/dev/null | tail -3 | head -1 || true)"
repo_stats="$(restic stats 2>/dev/null | grep "Total Size" || true)"
summary="✅ **Backup complete** — $(date '+%Y-%m-%d %H:%M')"
[[ $dumped -gt 0 ]] && summary+="\nDB dumps: $dumped stacks"
[[ ${#dump_errors[@]} -gt 0 ]] && summary+="\n⚠️ Failed dumps: ${dump_errors[*]}"
[[ -n "$snapshot_info" ]] && summary+="\nLatest: \`$snapshot_info\`"
[[ -n "$repo_stats" ]] && summary+="\nRepo: $repo_stats"
_notify "#36a64f" "$summary"
log "Backup complete"
}
cmd_backup() {
local sub="${1:-help}"; shift || true
case "$sub" in
install)
log "Installing backup service"
if $DRY_RUN; then
info "[dry-run] Would write /etc/systemd/system/stacks-backup.{service,timer}"
info "[dry-run] systemctl enable --now stacks-backup.timer"
return
fi
cat > /etc/systemd/system/stacks-backup.service << EOF
[Unit]
Description=Backup stacks data to HiDrive via restic
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
User=root
ExecStart=${SCRIPT_PATH} backup run
Environment=HOME=/root
EOF
cat > /etc/systemd/system/stacks-backup.timer << 'EOF'
[Unit]
Description=Daily stacks backup at 3:00 AM
[Timer]
OnCalendar=*-*-* 03:00:00 Europe/Amsterdam
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now stacks-backup.timer
ok "Backup service installed and enabled"
systemctl status stacks-backup.timer --no-pager || true
;;
uninstall)
log "Uninstalling backup service"
if $DRY_RUN; then info "[dry-run] Would uninstall backup service"; return; fi
systemctl disable --now stacks-backup.timer 2>/dev/null || true
rm -f /etc/systemd/system/stacks-backup.{service,timer}
systemctl daemon-reload
ok "Backup service removed"
;;
run)
log "Running backup now"
if $DRY_RUN; then info "[dry-run] Would run restic backup with dynamic DB detection"; return; fi
_backup_run
;;
status)
systemctl status stacks-backup.timer stacks-backup.service --no-pager 2>/dev/null \
|| warn "Backup service not installed"
;;
logs)
journalctl -u stacks-backup.service "${@}" --no-pager 2>/dev/null \
|| warn "No backup logs found (is the service installed?)"
;;
next)
systemctl list-timers stacks-backup.timer --no-pager 2>/dev/null \
|| warn "Timer not active"
;;
snapshots)
load_root_env
[[ -n "${RESTIC_REPOSITORY:-}" ]] || die "RESTIC_REPOSITORY not set in $ROOT_ENV"
[[ -n "${RESTIC_PASSWORD:-}" ]] || die "RESTIC_PASSWORD not set in $ROOT_ENV"
export RESTIC_REPOSITORY RESTIC_PASSWORD
restic snapshots 2>/dev/null || die "restic not installed or repo not initialised"
;;
help|"")
echo "Usage: $SCRIPT_NAME backup <subcommand>"
echo "Subcommands: install, uninstall, run, status, logs, next, snapshots"
;;
*)
die "Unknown backup subcommand: $sub (install|uninstall|run|status|logs|next|snapshots)"
;;
esac
}
# ─── New Stack Scaffold ───────────────────────────────────────────────────────
cmd_new() {
local name=""
local with_db=""
local with_redis=false
local no_traefik=false
while [[ $# -gt 0 ]]; do
case "$1" in
--db) with_db="${2:?--db requires a value (postgres|mysql|mariadb)}"; shift 2 ;;
--db=*) with_db="${1#--db=}"; shift ;;
--redis) with_redis=true; shift ;;
--no-traefik) no_traefik=true; shift ;;
-*) die "Unknown flag: $1" ;;
*) [[ -z "$name" ]] && name="$1" || die "Unexpected argument: $1"; shift ;;
esac
done
[[ -z "$name" ]] && die "Usage: $SCRIPT_NAME new NAME [--db postgres|mysql|mariadb] [--redis] [--no-traefik]"
[[ "$name" == _* ]] && die "Stack name cannot start with underscore (_)"
[[ "$name" =~ ^[a-z0-9_-]+$ ]] || die "Stack name must be lowercase alphanumeric (a-z, 0-9, -, _)"
local stack_dir="$STACKS_DIR/$name"
[[ -d "$stack_dir" ]] && die "Stack already exists: $name"
log "Scaffolding stack: ${BOLD}$name${RESET}"
[[ -n "$with_db" ]] && info " + database: $with_db"
$with_redis && info " + redis"
$no_traefik || info " + traefik labels"
if $DRY_RUN; then
info "[dry-run] Would create:"
info " $stack_dir/compose.yml"
info " $stack_dir/.env.example"
return
fi
mkdir -p "$stack_dir"
# --- .env.example ---
{
echo "# Network"
echo "NETWORK_NAME=falcon_network"
$no_traefik || echo "TRAEFIK_HOST=${name}.example.com"
echo ""
echo "# Timezone"
echo "TIMEZONE=Europe/Amsterdam"
echo ""
echo "# Application"
echo "# APP_SECRET=$(openssl rand -hex 32 2>/dev/null || echo 'changeme')"
if [[ -n "$with_db" ]]; then
echo ""
echo "# Database"
echo "DB_NAME=$name"
echo "DB_USER=$name"
echo "DB_PASSWORD=changeme"
fi
} > "$stack_dir/.env.example"
# --- compose.yml ---
{
echo "services:"
echo " app:"
echo " image: # TODO: set image"
echo " container_name: ${name}"
echo " environment:"
echo " TZ: \${TIMEZONE:-Europe/Amsterdam}"
if [[ -n "$with_db" ]]; then
echo " # DB_HOST: ${name}_db"
echo " # DB_NAME: \${DB_NAME}"
echo " # DB_USER: \${DB_USER}"
echo " # DB_PASSWORD: \${DB_PASSWORD}"
fi
if $with_redis; then
echo " # REDIS_URL: redis://${name}_redis:6379"
fi
echo " volumes:"
echo " - ../.data/${name}/data:/data"
echo " restart: always"
if [[ -n "$with_db" ]]; then
echo " depends_on:"
echo " db:"
echo " condition: service_healthy"
fi
echo " healthcheck:"
echo " test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:PORT/health\"]"
echo " interval: 30s"
echo " timeout: 5s"
echo " retries: 3"
echo " start_period: 15s"
if $no_traefik; then
echo " ports:"
echo " - \"8080:8080\" # TODO: adjust port"
else
cat << LABELS
labels:
- "traefik.enable=true"
- "traefik.http.routers.${name}-web.rule=Host(\`\${TRAEFIK_HOST}\`)"
- "traefik.http.routers.${name}-web.entrypoints=web"
- "traefik.http.routers.${name}-web.middlewares=${name}-redirect-web-secure"
- "traefik.http.middlewares.${name}-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.routers.${name}-web-secure.rule=Host(\`\${TRAEFIK_HOST}\`)"
- "traefik.http.routers.${name}-web-secure.entrypoints=web-secure"
- "traefik.http.routers.${name}-web-secure.tls.certresolver=resolver"
- "traefik.http.routers.${name}-web-secure.middlewares=security-headers@file"
- "traefik.http.services.${name}-web-secure.loadbalancer.server.port=8080"
- "traefik.docker.network=\${NETWORK_NAME}"
LABELS
fi
echo " networks:"
echo " - compose_network"
# --- DB service ---
if [[ -n "$with_db" ]]; then
echo ""
case "$with_db" in
postgres|postgresql)
cat << POSTGRES
db:
image: postgres:16-alpine
container_name: ${name}_db
environment:
POSTGRES_DB: \${DB_NAME:-$name}
POSTGRES_USER: \${DB_USER:-$name}
POSTGRES_PASSWORD: \${DB_PASSWORD:-changeme}
volumes:
- ../.data/${name}/db:/var/lib/postgresql/data
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \$\${POSTGRES_USER} -d \$\${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- compose_network
POSTGRES
;;
mysql)
cat << MYSQL
db:
image: mysql:8
container_name: ${name}_db
environment:
MYSQL_DATABASE: \${DB_NAME:-$name}
MYSQL_USER: \${DB_USER:-$name}
MYSQL_PASSWORD: \${DB_PASSWORD:-changeme}
MYSQL_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-rootchangeme}
volumes:
- ../.data/${name}/db:/var/lib/mysql
restart: always
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 5
networks:
- compose_network
MYSQL
;;
mariadb)
cat << MARIADB
db:
image: mariadb:11
container_name: ${name}_db
environment:
MARIADB_DATABASE: \${DB_NAME:-$name}
MARIADB_USER: \${DB_USER:-$name}
MARIADB_PASSWORD: \${DB_PASSWORD:-changeme}
MARIADB_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-rootchangeme}
volumes:
- ../.data/${name}/db:/var/lib/mysql
restart: always
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 5
networks:
- compose_network
MARIADB
;;
*)
die "Unknown DB type: $with_db (postgres|mysql|mariadb)"
;;
esac
fi
# --- Redis service ---
if $with_redis; then
echo ""
cat << REDIS
redis:
image: redis:7-alpine
container_name: ${name}_redis
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks:
- compose_network
REDIS
fi
echo ""
echo "networks:"
echo " compose_network:"
echo " name: \${NETWORK_NAME}"
echo " external: true"
} > "$stack_dir/compose.yml"
ok "Scaffolded: ${BOLD}$name${RESET}"
info " Edit: ${stack_dir}/compose.yml"
info " Env: cp ${stack_dir}/.env.example ${stack_dir}/.env && \$EDITOR ${stack_dir}/.env"
info " Start: $SCRIPT_NAME up $name"
}
# ─── Shell Completion ─────────────────────────────────────────────────────────
_completion_bash() {
local stacks_init="$1" # pre-rendered stacks array initialisation block
cat << 'EOF'
# stacks.sh bash completion — source this file or place in /etc/bash_completion.d/
_stacks_complete() {
local cur prev words cword
_init_completion 2>/dev/null || {
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
}
local script="${COMP_WORDS[0]}"
EOF
echo "$stacks_init"
cat << 'EOF'
local commands="ls ps up down restart pull logs exec run config images top stats compose update backup new completion version help"
local update_subs="install uninstall run status logs next"
local backup_subs="install uninstall run status logs next snapshots"
local global_flags="--dry-run --parallel --verbose --quiet --help --version"
case "${COMP_WORDS[1]}" in
update) COMPREPLY=( $(compgen -W "$update_subs" -- "$cur") ); return ;;
backup) COMPREPLY=( $(compgen -W "$backup_subs" -- "$cur") ); return ;;
completion) COMPREPLY=( $(compgen -W "bash zsh --install --static" -- "$cur") ); return ;;
new)
case "$prev" in
--db) COMPREPLY=( $(compgen -W "postgres mysql mariadb" -- "$cur") ); return ;;
new) COMPREPLY=(); return ;;
*) COMPREPLY=( $(compgen -W "--db --redis --no-traefik" -- "$cur") ); return ;;
esac
;;
exec|run)
if [[ $COMP_CWORD -eq 2 ]]; then
COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") )
fi
return ;;
compose)
if [[ $COMP_CWORD -eq 2 ]]; then
COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") )
fi
return ;;
down)
COMPREPLY=( $(compgen -W "${stacks[*]} --volumes" -- "$cur") ); return ;;
logs)
case "$prev" in
-n|--tail|--since|--until) COMPREPLY=(); return ;;
esac
COMPREPLY=( $(compgen -W "${stacks[*]} -f --follow -n --tail --since --until -t --timestamps" -- "$cur") )
return ;;
up|restart|pull|ps|config|images|top|stats)
COMPREPLY=( $(compgen -W "${stacks[*]}" -- "$cur") ); return ;;
esac
if [[ "$cur" == -* ]]; then
COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ); return
fi
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
fi
}
complete -F _stacks_complete stacks.sh
complete -F _stacks_complete stacks
EOF
}
_completion_zsh() {
local stacks_init="$1" # pre-rendered stacks array initialisation block
cat << 'EOF'
#compdef stacks.sh stacks
_stacks() {
local script="${words[1]}"
EOF
echo "$stacks_init"
cat << 'EOF'
local -a commands=(
'ls:List all stacks with live status'
'ps:Show container status'
'up:Start stack(s)'
'down:Stop stack(s)'
'restart:Restart stack(s)'
'pull:Pull latest images'
'logs:Show logs'
'exec:Execute command in a running container'
'run:Run a one-off command in a new container'
'config:Show resolved compose config'
'images:Show images used by stack(s)'
'top:Display running processes'
'stats:Live container resource stats'
'compose:Pass-through to docker compose'
'update:Manage the update systemd service'
'backup:Manage the backup systemd service'
'new:Scaffold a new stack'
'completion:Generate shell completion script'
'version:Show version'
'help:Show help'
)
local -a update_subs=(
'install:Install and enable update timer'
'uninstall:Remove update timer'
'run:Run update now'
'status:Show update service status'
'logs:Show update service logs'
'next:Show next scheduled run'
)
local -a backup_subs=(
'install:Install and enable backup timer'
'uninstall:Remove backup timer'
'run:Run backup now'
'status:Show backup service status'
'logs:Show backup service logs'
'next:Show next scheduled run'
'snapshots:List restic snapshots'
)
_arguments -C \
'(-n --dry-run)'{-n,--dry-run}'[Show what would be done without executing]' \
'(-p --parallel)'{-p,--parallel}'[Run operations in parallel]' \
'(-v --verbose)'{-v,--verbose}'[Verbose output]' \
'(-q --quiet)'{-q,--quiet}'[Suppress non-error output]' \
'(-h --help)'{-h,--help}'[Show help]' \
'--version[Show version]' \
'1:command:->cmd' \
'*:args:->args' && return
case "$state" in
cmd)
_describe 'command' commands
;;
args)
case "${words[2]}" in
up|restart|pull|ps|config|images|top|stats)
_values 'stack' "${stacks[@]}"
;;
down)
_arguments \
'--volumes[Also remove volumes]' \
'*:stack:'"(${stacks[*]})"
;;
logs)
_arguments \
'(-f --follow)'{-f,--follow}'[Follow log output]' \
'(-n --tail)'{-n,--tail}'[Number of lines to show]:lines' \
'--since[Show logs since timestamp]:timestamp' \
'--until[Show logs until timestamp]:timestamp' \
'(-t --timestamps)'{-t,--timestamps}'[Show timestamps]' \
'*:stack:'"(${stacks[*]})"
;;
exec|run|compose)
if [[ $CURRENT -eq 3 ]]; then
_values 'stack' "${stacks[@]}"
fi
;;
update)
_describe 'subcommand' update_subs
;;
backup)
_describe 'subcommand' backup_subs
;;
new)
_arguments \
'--db[Add a database service]:engine:(postgres mysql mariadb)' \
'--redis[Add a Redis service]' \
'--no-traefik[Skip Traefik labels, expose port instead]' \
'1:name:_message "stack name"'
;;
completion)
_arguments \
'--install[Install the completion file]' \
'--static[Bake current stack list into completion script]' \
'1:shell:(bash zsh)'
;;
esac
;;
esac
}
_stacks "$@"
EOF
}
cmd_completion() {
local shell="" install=false static=false
for arg in "$@"; do
case "$arg" in
bash|zsh) shell="$arg" ;;
--install) install=true ;;
--static) static=true ;;
*) die "Unknown argument: $arg (bash|zsh [--install] [--static])" ;;
esac
done
if [[ -z "$shell" ]]; then
shell="$(basename "${SHELL:-bash}")"
[[ "$shell" == "bash" || "$shell" == "zsh" ]] || shell="bash"
warn "No shell specified, defaulting to: $shell"
fi
# Build the stacks array initialisation block — either dynamic (runtime discovery)
# or static (current list baked in at generation time).
local bash_stacks_init zsh_stacks_init
if $static; then
local -a cur_stacks=()
mapfile -t cur_stacks < <(list_all_stacks)
[[ ${#cur_stacks[@]} -eq 0 ]] && die "No stacks found to bake into completion"
local generated_at
generated_at="$(date '+%Y-%m-%d')"
bash_stacks_init=" # Stacks baked in at generation time (${generated_at}) — rerun to update
local -a stacks=(${cur_stacks[*]})"
zsh_stacks_init=" # Stacks baked in at generation time (${generated_at}) — rerun to update
local -a stacks=(${cur_stacks[*]})"
info "Baking ${#cur_stacks[@]} stacks: ${cur_stacks[*]}"
else
bash_stacks_init=' # Dynamically discover stacks from the script location at completion time
local stacks_dir
stacks_dir="$(cd "$(dirname "$(realpath "$script" 2>/dev/null || echo "$script")")" 2>/dev/null && pwd)"
local -a stacks=()
if [[ -d "$stacks_dir" ]]; then
for d in "$stacks_dir"/*/; do
local n; n="$(basename "$d")"
[[ "$n" == _* ]] && continue
[[ -f "$d/compose.yml" ]] && stacks+=("$n")
done
fi'
zsh_stacks_init=' # Dynamically discover stacks from the script location at completion time
local stacks_dir
stacks_dir="$(cd "$(dirname "$(realpath "$script" 2>/dev/null || echo "$script")")" 2>/dev/null && pwd)"
local -a stacks=()
if [[ -d "$stacks_dir" ]]; then
for d in "$stacks_dir"/*/; do
local n="${d:t}"
[[ "$n" == _* ]] && continue
[[ -f "${d}compose.yml" ]] && stacks+=("$n")
done
fi'
fi
local content
case "$shell" in
bash) content="$(_completion_bash "$bash_stacks_init")" ;;
zsh) content="$(_completion_zsh "$zsh_stacks_init")" ;;
*) die "Unsupported shell: $shell (bash|zsh)" ;;
esac
if ! $install; then
echo "$content"
return
fi
local dest
case "$shell" in
bash)
if [[ -w /etc/bash_completion.d ]]; then
dest="/etc/bash_completion.d/stacks"
else
dest="${HOME}/.local/share/bash-completion/completions/stacks"
mkdir -p "$(dirname "$dest")"
fi
;;
zsh)
dest=""
# prefer first writable fpath dir
local -a fpath_dirs=()
while IFS=: read -ra fpath_dirs; do :; done <<< "${FPATH:-}"
for d in "${fpath_dirs[@]}"; do
[[ -d "$d" && -w "$d" ]] && { dest="$d/_stacks"; break; }
done
if [[ -z "$dest" ]]; then
dest="${HOME}/.zsh/completions/_stacks"
mkdir -p "$(dirname "$dest")"
info "Add to ~/.zshrc: ${BOLD}fpath=(~/.zsh/completions \$fpath)${RESET}"
info "Then reload: ${BOLD}autoload -U compinit && compinit${RESET}"
fi
;;
esac
if $DRY_RUN; then
info "[dry-run] Would write $shell completion to: $dest"
return
fi
echo "$content" > "$dest"
ok "$shell completion installed: $dest"
[[ "$shell" == "bash" ]] && info "Reload: source $dest"
}
# ─── Help ─────────────────────────────────────────────────────────────────────
cmd_help() {
cat << EOF
${BOLD}stacks.sh${RESET} v${VERSION} — Docker Compose stack manager
${BOLD}USAGE${RESET}
${CYAN}${SCRIPT_NAME}${RESET} [OPTIONS] COMMAND [FILTER...] [-- ARGS...]
${BOLD}GLOBAL OPTIONS${RESET}
-n, --dry-run Show commands without executing
-p, --parallel Run multi-stack operations in parallel
-v, --verbose Print each docker compose call before running
-q, --quiet Suppress informational output
-h, --help Show this help
--version Show version (${VERSION})
${BOLD}STACK COMMANDS${RESET}
${CYAN}ls${RESET} List stacks with live container status
${CYAN}ps${RESET} [FILTER...] Container status table
${CYAN}up${RESET} [FILTER...] Start stacks (compose up -d)
${CYAN}down${RESET} [FILTER...] [-v] Stop stacks (--volumes to also remove volumes)
${CYAN}restart${RESET} [FILTER...] Restart stacks
${CYAN}pull${RESET} [FILTER...] Pull latest images
${CYAN}logs${RESET} [FILTER...] [FLAGS] Show logs (-f to follow, -n N for tail)
${CYAN}config${RESET} [FILTER...] Show resolved compose config
${CYAN}images${RESET} [FILTER...] Show images used by stacks
${CYAN}top${RESET} [FILTER...] Display running processes
${CYAN}stats${RESET} [FILTER...] Live container resource usage
${BOLD}SINGLE-STACK COMMANDS${RESET}
${CYAN}exec${RESET} STACK SVC CMD... Execute in a running container
${CYAN}run${RESET} STACK SVC CMD... Run a one-off command (--rm)
${CYAN}compose${RESET} STACK [CMD...] Pass-through to docker compose
${BOLD}SERVICE MANAGEMENT${RESET}
${CYAN}update${RESET} install Link & enable systemd update timer
uninstall Disable & remove update timer
run Run the update script now
status Show timer/service status
logs Show service journal logs
next Show next scheduled run
${CYAN}backup${RESET} install|uninstall|run|status|logs|next|snapshots
${BOLD}UTILITIES${RESET}
${CYAN}new${RESET} NAME [OPTIONS] Scaffold a new stack
--db postgres|mysql|mariadb Add a database service
--redis Add a Redis service
--no-traefik Expose port instead of Traefik labels
${CYAN}completion${RESET} bash|zsh [--install] [--static] Generate/install shell completion
--static Bake current stack list in (no runtime discovery)
${CYAN}version${RESET} Print version
${CYAN}help${RESET} Show this help
${BOLD}FILTER SYNTAX${RESET}
One or more stack names or glob patterns, space- or comma-separated.
Multiple filters are OR-merged and de-duplicated.
Omit FILTER to match all stacks.
Examples:
gitea traefik exact names
'g*' glob: all stacks starting with g
'*,!traefik' (note: negation not supported — use exclusion manually)
${BOLD}EXAMPLES${RESET}
${SCRIPT_NAME} ls
${SCRIPT_NAME} up
${SCRIPT_NAME} up gitea traefik
${SCRIPT_NAME} down 'g*' --volumes
${SCRIPT_NAME} restart 'g*,traefik'
${SCRIPT_NAME} logs -f gitea
${SCRIPT_NAME} logs -n 100 gitea n8n
${SCRIPT_NAME} exec gitea gitea gitea admin user list
${SCRIPT_NAME} run passbolt passbolt bin/cake passbolt healthcheck
${SCRIPT_NAME} pull --parallel
${SCRIPT_NAME} pull --dry-run
${SCRIPT_NAME} update run
${SCRIPT_NAME} update install
${SCRIPT_NAME} backup run
${SCRIPT_NAME} backup snapshots
${SCRIPT_NAME} new myapp --db postgres --redis
${SCRIPT_NAME} completion zsh --install
EOF
}
# ─── Entry Point ──────────────────────────────────────────────────────────────
main() {
local -a args=()
# Parse global flags first (allow them anywhere before the command)
for arg in "$@"; do
case "$arg" in
-n|--dry-run) DRY_RUN=true ;;
-p|--parallel) PARALLEL=true ;;
-v|--verbose) VERBOSE=true ;;
-q|--quiet) QUIET=true ;;
-h|--help) cmd_help; exit 0 ;;
--version) echo "stacks.sh v$VERSION"; exit 0 ;;
--) break ;;
*) args+=("$arg") ;;
esac
done
local command="${args[0]:-}"
local -a rest=("${args[@]:1}")
$DRY_RUN && warn "Dry-run mode — no changes will be made"
case "$command" in
ls|list) cmd_ls ;;
ps|status) cmd_ps "${rest[@]+"${rest[@]}"}" ;;
up|start) cmd_up "${rest[@]+"${rest[@]}"}" ;;
down|stop) cmd_down "${rest[@]+"${rest[@]}"}" ;;
restart) cmd_restart "${rest[@]+"${rest[@]}"}" ;;
pull) cmd_pull "${rest[@]+"${rest[@]}"}" ;;
logs|log) cmd_logs "${rest[@]+"${rest[@]}"}" ;;
exec) cmd_exec "${rest[@]+"${rest[@]}"}" ;;
run) cmd_run_svc "${rest[@]+"${rest[@]}"}" ;;
config) cmd_config "${rest[@]+"${rest[@]}"}" ;;
images) cmd_images "${rest[@]+"${rest[@]}"}" ;;
top) cmd_top "${rest[@]+"${rest[@]}"}" ;;
stats) cmd_stats "${rest[@]+"${rest[@]}"}" ;;
compose) cmd_compose "${rest[@]+"${rest[@]}"}" ;;
update) cmd_update "${rest[@]+"${rest[@]}"}" ;;
backup) cmd_backup "${rest[@]+"${rest[@]}"}" ;;
new|scaffold) cmd_new "${rest[@]+"${rest[@]}"}" ;;
completion) cmd_completion "${rest[@]+"${rest[@]}"}" ;;
version) echo "stacks.sh v$VERSION" ;;
help|"") cmd_help ;;
*)
err "Unknown command: ${BOLD}$command${RESET}"
echo -e " Run ${CYAN}$SCRIPT_NAME help${RESET} for usage."
exit 1
;;
esac
}
main "$@"