#!/usr/bin/env bash set -Eeuo pipefail ARCHIVE_PATH="" DEPLOY_PATH="" COMPOSE_FILE="" PROJECT_NAME="" SERVICE_NAME="" NGINX_SOURCE="" NGINX_TARGET="" NGINX_COMPOSE_FILE="" NGINX_PROJECT_NAME="" usage() { cat <<'EOF' Usage: deploy-remote.sh --archive --deploy-path --compose-file --project-name deploy-remote.sh --archive --deploy-path --compose-file --project-name [--service ] deploy-remote.sh --archive --deploy-path --compose-file --project-name \ [--service ] [--nginx-source ] [--nginx-target ] \ [--nginx-compose-file ] [--nginx-project-name ] This script only updates the main Goodwalk compose project at the specified deployment path. It does not touch unrelated Docker projects or global Docker state. EOF } fail() { echo "[deploy-remote] ERROR: $*" >&2 exit 1 } while [[ $# -gt 0 ]]; do case "$1" in --archive) ARCHIVE_PATH="${2:-}" shift 2 ;; --deploy-path) DEPLOY_PATH="${2:-}" shift 2 ;; --compose-file) COMPOSE_FILE="${2:-}" shift 2 ;; --project-name) PROJECT_NAME="${2:-}" shift 2 ;; --service) SERVICE_NAME="${2:-}" shift 2 ;; --nginx-source) NGINX_SOURCE="${2:-}" shift 2 ;; --nginx-target) NGINX_TARGET="${2:-}" shift 2 ;; --nginx-compose-file) NGINX_COMPOSE_FILE="${2:-}" shift 2 ;; --nginx-project-name) NGINX_PROJECT_NAME="${2:-}" shift 2 ;; -h|--help) usage exit 0 ;; *) fail "Unknown argument: $1" ;; esac done [[ -n "$ARCHIVE_PATH" ]] || fail "--archive is required" [[ -n "$DEPLOY_PATH" ]] || fail "--deploy-path is required" [[ -n "$COMPOSE_FILE" ]] || fail "--compose-file is required" [[ -n "$PROJECT_NAME" ]] || fail "--project-name is required" if [[ -n "$SERVICE_NAME" ]]; then SERVICE_NAME="$(printf '%s' "$SERVICE_NAME" | xargs)" fi [[ "$DEPLOY_PATH" != "/" ]] || fail "Refusing to deploy to /" [[ -f "$ARCHIVE_PATH" ]] || fail "Archive not found: $ARCHIVE_PATH" nginx_args=("$NGINX_SOURCE" "$NGINX_TARGET" "$NGINX_COMPOSE_FILE" "$NGINX_PROJECT_NAME") nginx_args_present=0 for value in "${nginx_args[@]}"; do if [[ -n "$value" ]]; then nginx_args_present=1 break fi done if (( nginx_args_present )); then [[ -n "$NGINX_SOURCE" ]] || fail "--nginx-source is required when nginx deployment is enabled" [[ -n "$NGINX_TARGET" ]] || fail "--nginx-target is required when nginx deployment is enabled" [[ -n "$NGINX_COMPOSE_FILE" ]] || fail "--nginx-compose-file is required when nginx deployment is enabled" [[ -n "$NGINX_PROJECT_NAME" ]] || fail "--nginx-project-name is required when nginx deployment is enabled" fi if docker compose version >/dev/null 2>&1; then COMPOSE_CMD=(docker compose) elif command -v docker-compose >/dev/null 2>&1; then COMPOSE_CMD=(docker-compose) else fail "Docker Compose is not installed on the server" fi STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-deploy.XXXXXX")" MAINTENANCE_ACTIVE=0 clear_maintenance_flag() { if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then echo "[deploy-remote] Clearing maintenance flag" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" \ exec -T nginx rm -f /etc/nginx/conf.d/maintenance.flag || true MAINTENANCE_ACTIVE=0 fi } cleanup() { clear_maintenance_flag rm -rf "$STAGING_DIR" } trap cleanup EXIT echo "[deploy-remote] Deploying main Goodwalk stack" echo "[deploy-remote] Target deployment path: $DEPLOY_PATH" echo "[deploy-remote] Compose file: $COMPOSE_FILE" echo "[deploy-remote] Docker project: $PROJECT_NAME" if [[ -n "$SERVICE_NAME" ]]; then echo "[deploy-remote] Target service: $SERVICE_NAME" fi if (( nginx_args_present )); then echo "[deploy-remote] Nginx config source: $NGINX_SOURCE" echo "[deploy-remote] Nginx config target: $NGINX_TARGET" echo "[deploy-remote] Nginx compose file: $NGINX_COMPOSE_FILE" echo "[deploy-remote] Nginx project: $NGINX_PROJECT_NAME" fi echo "[deploy-remote] Staging archive in: $STAGING_DIR" mkdir -p "$DEPLOY_PATH" tar -xzf "$ARCHIVE_PATH" -C "$STAGING_DIR" [[ -f "$STAGING_DIR/$COMPOSE_FILE" ]] || fail "Compose file missing from uploaded archive: $COMPOSE_FILE" if [[ -f "$DEPLOY_PATH/.env" ]]; then echo "[deploy-remote] Preserving existing $DEPLOY_PATH/.env" fi echo "[deploy-remote] Copying application files into $DEPLOY_PATH" if command -v rsync >/dev/null 2>&1; then rsync -a \ --exclude '.env' \ --exclude '.env.*' \ "$STAGING_DIR"/ "$DEPLOY_PATH"/ else while IFS= read -r -d '' item; do relative_path="${item#"$STAGING_DIR"/}" if [[ "$relative_path" == ".env" || "$relative_path" == .env.* ]]; then continue fi destination="$DEPLOY_PATH/$relative_path" if [[ -d "$item" ]]; then mkdir -p "$destination" continue fi mkdir -p "$(dirname "$destination")" cp -f "$item" "$destination" done < <(find "$STAGING_DIR" -mindepth 1 -print0) fi [[ -f "$DEPLOY_PATH/$COMPOSE_FILE" ]] || fail "Compose file missing after copy: $DEPLOY_PATH/$COMPOSE_FILE" if [[ ! -f "$DEPLOY_PATH/.env" ]]; then if [[ -f "$DEPLOY_PATH/deploy.env.template" ]]; then echo "[deploy-remote] No remote .env found. Creating $DEPLOY_PATH/.env from deploy.env.template" cp "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env" else fail "Remote .env is missing and deploy.env.template was not uploaded" fi fi merge_env_file() { local template="$1" local live="$2" [[ -f "$template" ]] || { echo "[deploy-remote] No env template at $template, skipping merge"; return 0; } [[ -f "$live" ]] || { echo "[deploy-remote] No live .env at $live, skipping merge"; return 0; } local added diffs backup added="$(mktemp)" diffs="$(mktemp)" backup="${live}.bak.$(date -u +%Y%m%dT%H%M%SZ)" awk -v live="$live" -v added_log="$added" -v diff_log="$diffs" ' function trim(s) { sub(/^[ \t]+/,"",s); sub(/[ \t]+$/,"",s); return s } BEGIN { while ((getline line < live) > 0) { if (line ~ /^[ \t]*#/ || line ~ /^[ \t]*$/) continue eq = index(line, "=") if (eq == 0) continue k = trim(substr(line, 1, eq-1)) v = substr(line, eq+1) live_keys[k] = v live_seen[k] = 1 } close(live) } /^[ \t]*#/ || /^[ \t]*$/ { next } { eq = index($0, "=") if (eq == 0) next k = trim(substr($0, 1, eq-1)) v = substr($0, eq+1) if (!(k in live_seen)) { print k "=" v >> added_log } else if (live_keys[k] != v) { print k " (template=" v " | live=" live_keys[k] ")" >> diff_log } } ' "$template" if [[ -s "$added" ]]; then cp "$live" "$backup" echo "[deploy-remote] Adding env keys present in template but missing from $live:" sed 's/^/ + /' "$added" { printf '\n# Appended by deploy-remote.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" cat "$added" } >> "$live" echo "[deploy-remote] Backup of previous .env written to $backup" else echo "[deploy-remote] .env is up to date with template (no missing keys)" fi if [[ -s "$diffs" ]]; then echo "[deploy-remote] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:" sed 's/^/ ! /' "$diffs" echo "[deploy-remote] If a live value is stale (e.g. an old OWNER_EMAIL), edit $live and re-deploy." fi rm -f "$added" "$diffs" } merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env" cd "$DEPLOY_PATH" echo "[deploy-remote] Validating compose configuration" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config >/dev/null if [[ -n "$SERVICE_NAME" ]]; then AVAILABLE_SERVICES="$("${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" config --services)" if ! grep -Fxq "$SERVICE_NAME" <<<"$AVAILABLE_SERVICES"; then fail "Service '$SERVICE_NAME' was not found in $COMPOSE_FILE. Available services: $(tr '\n' ',' <<<"$AVAILABLE_SERVICES" | sed 's/,$//')" fi fi if (( nginx_args_present )); then [[ -f "$DEPLOY_PATH/$NGINX_SOURCE" ]] || fail "Nginx config missing from deployment payload: $DEPLOY_PATH/$NGINX_SOURCE" [[ -f "$NGINX_COMPOSE_FILE" ]] || fail "Nginx compose file was not found on the server: $NGINX_COMPOSE_FILE" MAINTENANCE_HTML_SRC="$DEPLOY_PATH/nginx/maintenance.html" MAINTENANCE_LOGO_SRC="$DEPLOY_PATH/nginx/logo.png" [[ -f "$MAINTENANCE_HTML_SRC" ]] || fail "Maintenance page missing from deployment payload: $MAINTENANCE_HTML_SRC" [[ -f "$MAINTENANCE_LOGO_SRC" ]] || fail "Maintenance logo missing from deployment payload: $MAINTENANCE_LOGO_SRC" echo "[deploy-remote] Updating shared nginx config (pre-rebuild) so maintenance routing is active" mkdir -p "$(dirname "$NGINX_TARGET")" cp "$DEPLOY_PATH/$NGINX_SOURCE" "$NGINX_TARGET" echo "[deploy-remote] Installing maintenance page assets into nginx container" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" \ exec -T nginx mkdir -p /var/www/html/m "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" \ cp "$MAINTENANCE_HTML_SRC" nginx:/var/www/html/maintenance.html "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" \ cp "$MAINTENANCE_LOGO_SRC" nginx:/var/www/html/m/logo.png echo "[deploy-remote] Validating nginx configuration" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t echo "[deploy-remote] Reloading shared nginx so the new config (incl. maintenance routing) is live" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload echo "[deploy-remote] Engaging maintenance page (touch maintenance.flag)" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" \ exec -T nginx touch /etc/nginx/conf.d/maintenance.flag MAINTENANCE_ACTIVE=1 fi if [[ -n "$SERVICE_NAME" ]]; then echo "[deploy-remote] Stopping only the Goodwalk service: $SERVICE_NAME" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true echo "[deploy-remote] Rebuilding and starting only the Goodwalk service: $SERVICE_NAME" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build "$SERVICE_NAME" else echo "[deploy-remote] Stopping only the Goodwalk project containers" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true echo "[deploy-remote] Rebuilding and starting only the Goodwalk project containers" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans fi echo "[deploy-remote] Current Goodwalk container status" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db" ]]; then echo "[deploy-remote] Syncing homepage content into PostgreSQL" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs fi clear_maintenance_flag echo "[deploy-remote] Remote deployment finished"