#!/usr/bin/env bash # stack-deploy.sh — Safe Docker Swarm stack deployer # Supports: # - Flat: .yaml (legacy, all existing stacks) # - Folder: / (new, multi-compose stacks like immich) # # Folder layout: # compose-files/ # └── immich/ # ├── immich.yml ← main compose (must match stack name) # ├── hwaccel.transcoding.yml ← extension files, merged in glob order # ├── hwaccel.ml.yml # └── immich.env ← optional, same as flat pattern # # Env layering: # deploy/global.env ← always loaded first (DOMAIN, DOMAIN_NAME, ALT_DOMAIN, etc.) # .env ← loaded second; stack values override globals # # Merge strategy: docker compose ... config | docker stack deploy -c - # All files in the folder matching *.yml or *.yaml are included. # Main file (.yml or .yaml) is always passed FIRST. # Remaining files are sorted and appended. set -euo pipefail STACK="${1:?Usage: stack-deploy.sh }" DIR="/volume1/docker/compose-files" PY="$DIR/deploy/envparse.py" GLOBAL_ENV="$DIR/deploy/global.env" # ── Locate compose file(s) ────────────────────────────────────────────────── FOLDER="$DIR/$STACK" FLAT_YAML="$DIR/${STACK}.yaml" FLAT_YML="$DIR/${STACK}.yml" if [ -d "$FOLDER" ]; then MODE="folder" # Main file must be named .yml or .yaml if [ -f "$FOLDER/${STACK}.yml" ]; then MAIN="$FOLDER/${STACK}.yml" elif [ -f "$FOLDER/${STACK}.yaml" ]; then MAIN="$FOLDER/${STACK}.yaml" else echo "ERROR: No main compose file ($STACK.yml or $STACK.yaml) in $FOLDER"; exit 1 fi # Collect extension files: everything else in the folder, sorted EXTRAS=() while IFS= read -r -d '' f; do [ "$f" = "$MAIN" ] && continue EXTRAS+=("$f") done < <(find "$FOLDER" -maxdepth 1 \( -name '*.yml' -o -name '*.yaml' \) -print0 | sort -z) ENVFILE="$FOLDER/${STACK}.env" elif [ -f "$FLAT_YAML" ]; then MODE="flat" MAIN="$FLAT_YAML" EXTRAS=() ENVFILE="$DIR/${STACK}.env" elif [ -f "$FLAT_YML" ]; then MODE="flat" MAIN="$FLAT_YML" EXTRAS=() ENVFILE="$DIR/${STACK}.env" else echo "ERROR: No compose file or folder found for stack '$STACK' in $DIR" exit 1 fi echo "==> Deploying stack: $STACK [$MODE]" echo " Main: $MAIN" for f in "${EXTRAS[@]:-}"; do [ -n "$f" ] && echo " Extra: $f"; done # ── Build -f flag list ─────────────────────────────────────────────────────── F_FLAGS=(-f "$MAIN") for f in "${EXTRAS[@]:-}"; do [ -n "$f" ] && F_FLAGS+=(-f "$f") done # ── Load env (global base + optional stack override) ───────────────────────── HINT=" Hint: 'secret not found' means Woodpecker hasn't provisioned secrets yet.\n Trigger the pipeline: https://woodpecker.bryanmail.net\n" GLOBAL_EXISTS=0 STACK_EXISTS=0 [ -f "$GLOBAL_ENV" ] && GLOBAL_EXISTS=1 [ -f "$ENVFILE" ] && STACK_EXISTS=1 if [ "$GLOBAL_EXISTS" -eq 1 ] && [ "$STACK_EXISTS" -eq 1 ]; then echo " Env: $GLOBAL_ENV + $ENVFILE (stack overrides global)" eval "$(python3 "$PY" export_merged "$GLOBAL_ENV" "$ENVFILE")" VARS="$(python3 "$PY" vars_merged "$GLOBAL_ENV" "$ENVFILE")" elif [ "$GLOBAL_EXISTS" -eq 1 ]; then echo " Env: $GLOBAL_ENV (no stack env)" eval "$(python3 "$PY" export "$GLOBAL_ENV")" VARS="$(python3 "$PY" vars "$GLOBAL_ENV")" elif [ "$STACK_EXISTS" -eq 1 ]; then echo " Env: $ENVFILE (no global env)" eval "$(python3 "$PY" export "$ENVFILE")" VARS="$(python3 "$PY" vars "$ENVFILE")" else echo " Env: (none)" VARS="" fi echo " Vars: $VARS" # ── Deploy ─────────────────────────────────────────────────────────────────── if [ -n "$VARS" ]; then if [ "${#F_FLAGS[@]}" -gt 2 ]; then # Folder mode with extras: merge via docker compose config docker compose "${F_FLAGS[@]}" config \ | python3 "$PY" strip \ | envsubst "$VARS" \ | docker stack deploy -c - "$STACK" \ || { printf "\n%b" "$HINT"; exit 1; } else # Single file envsubst "$VARS" < "$MAIN" \ | python3 "$PY" strip \ | docker stack deploy -c - "$STACK" \ || { printf "\n%b" "$HINT"; exit 1; } fi else if [ "${#F_FLAGS[@]}" -gt 2 ]; then docker compose "${F_FLAGS[@]}" config \ | python3 "$PY" strip \ | docker stack deploy -c - "$STACK" \ || { printf "\n%b" "$HINT"; exit 1; } else docker stack deploy -c "$MAIN" "$STACK" \ || { printf "\n%b" "$HINT"; exit 1; } fi fi echo "==> Done: $STACK"