#!/usr/bin/env bash set -Eeuo pipefail REPO_URL="" BRANCH="main" REF="" DEPLOY_PATH="" COMPOSE_FILE="" PROJECT_NAME="" SERVICE_NAME="" NGINX_SOURCE="" NGINX_TARGET="" NGINX_COMPOSE_FILE="" NGINX_PROJECT_NAME="" MAINTENANCE_HOST_DIR="" MAINTENANCE_FLAG_PATH="" VERIFY_URL="https://www.goodwalk.co.nz/api/health" SKIP_SITE_CHECK=0 usage() { cat <<'EOF' Usage: deploy-from-git.sh --repo-url --branch --deploy-path --compose-file --project-name deploy-from-git.sh --repo-url [--branch ] [--ref ] --deploy-path --compose-file --project-name [--service ] deploy-from-git.sh --repo-url [--branch ] [--ref ] --deploy-path --compose-file --project-name \ [--service ] [--nginx-source ] [--nginx-target ] \ [--nginx-compose-file ] [--nginx-project-name ] \ [--maintenance-host-dir ] [--maintenance-flag ] \ [--verify-url ] [--skip-site-check] This script clones or fetches the application repo on the server, exports the homepage content payload, updates only the main Goodwalk compose project, and optionally updates the shared nginx stack plus maintenance mode handling. Authentication for private HTTPS repos is expected to come from ~/.netrc, git-credential, or another Git-supported credential mechanism already present on the server. EOF } fail() { echo "[deploy-git] ERROR: $*" >&2 exit 1 } assert_command() { command -v "$1" >/dev/null 2>&1 || fail "Required command '$1' is not installed on the server" } run_homepage_export() { local export_script="$1" local output_path="$2" if command -v node >/dev/null 2>&1; then node --experimental-strip-types "$export_script" "$output_path" return fi echo "[deploy-git] Host node not found; exporting homepage content via temporary node:22-alpine container" docker run --rm \ -v "$CHECKOUT_DIR:/app" \ -w /app \ node:22-alpine \ node --experimental-strip-types "${export_script#/app/}" "${output_path#/app/}" } while [[ $# -gt 0 ]]; do case "$1" in --repo-url) REPO_URL="${2:-}" shift 2 ;; --branch) BRANCH="${2:-}" shift 2 ;; --ref) REF="${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 ;; --maintenance-host-dir) MAINTENANCE_HOST_DIR="${2:-}" shift 2 ;; --maintenance-flag) MAINTENANCE_FLAG_PATH="${2:-}" shift 2 ;; --verify-url) VERIFY_URL="${2:-}" shift 2 ;; --skip-site-check) SKIP_SITE_CHECK=1 shift ;; -h|--help) usage exit 0 ;; *) fail "Unknown argument: $1" ;; esac done [[ -n "$REPO_URL" ]] || fail "--repo-url is required" [[ -n "$BRANCH" ]] || fail "--branch 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 /" 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" [[ -n "$MAINTENANCE_HOST_DIR" ]] || fail "--maintenance-host-dir is required when nginx deployment is enabled" [[ -n "$MAINTENANCE_FLAG_PATH" ]] || fail "--maintenance-flag is required when nginx deployment is enabled" fi assert_command git assert_command docker 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 WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/goodwalk-git-deploy.XXXXXX")" CHECKOUT_DIR="$WORK_DIR/repo" PAYLOAD_DIR="$WORK_DIR/payload" MAINTENANCE_ACTIVE=0 clear_maintenance_flag() { if (( MAINTENANCE_ACTIVE )) && (( nginx_args_present )); then echo "[deploy-git] Clearing maintenance flag at $MAINTENANCE_FLAG_PATH" rm -f "$MAINTENANCE_FLAG_PATH" || true MAINTENANCE_ACTIVE=0 fi } cleanup() { clear_maintenance_flag rm -rf "$WORK_DIR" } copy_checkout_to_payload() { mkdir -p "$PAYLOAD_DIR" if command -v rsync >/dev/null 2>&1; then rsync -a \ --exclude '.git' \ --exclude '.env' \ --exclude '.env.*' \ "$CHECKOUT_DIR"/ "$PAYLOAD_DIR"/ return fi while IFS= read -r -d '' item; do relative_path="${item#"$CHECKOUT_DIR"/}" case "$relative_path" in .git|.git/*|.env|.env.*) continue ;; esac destination="$PAYLOAD_DIR/$relative_path" if [[ -d "$item" ]]; then mkdir -p "$destination" continue fi mkdir -p "$(dirname "$destination")" cp -f "$item" "$destination" done < <(find "$CHECKOUT_DIR" -mindepth 1 -print0) } copy_payload_to_deploy() { mkdir -p "$DEPLOY_PATH" if command -v rsync >/dev/null 2>&1; then rsync -a \ --exclude '.env' \ --exclude '.env.*' \ "$PAYLOAD_DIR"/ "$DEPLOY_PATH"/ return fi while IFS= read -r -d '' item; do relative_path="${item#"$PAYLOAD_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 "$PAYLOAD_DIR" -mindepth 1 -print0) } merge_env_file() { local template="$1" local live="$2" [[ -f "$template" ]] || { echo "[deploy-git] No env template at $template, skipping merge"; return 0; } [[ -f "$live" ]] || { echo "[deploy-git] 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-git] Adding env keys present in template but missing from $live:" sed 's/^/ + /' "$added" { printf '\n# Appended by deploy-from-git.sh on %s from deploy.env.template\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" cat "$added" } >> "$live" echo "[deploy-git] Backup of previous .env written to $backup" else echo "[deploy-git] .env is up to date with template (no missing keys)" fi if [[ -s "$diffs" ]]; then echo "[deploy-git] NOTE: these keys exist in both files but values differ. Live values are PRESERVED:" sed 's/^/ ! /' "$diffs" echo "[deploy-git] If a live value is stale, edit $live and re-deploy." fi rm -f "$added" "$diffs" } check_site() { if (( SKIP_SITE_CHECK )) || [[ -z "$VERIFY_URL" ]]; then return 0 fi echo "[deploy-git] Checking production site: $VERIFY_URL" if command -v curl >/dev/null 2>&1; then local http_code if http_code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 30 -L "$VERIFY_URL" 2>/dev/null)"; then echo "[deploy-git] Site responded with HTTP $http_code" else echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2 fi return 0 fi if command -v wget >/dev/null 2>&1; then if wget --spider --server-response --timeout=30 "$VERIFY_URL" >/tmp/goodwalk-site-check.$$ 2>&1; then awk '/^ HTTP\// { code=$2 } END { if (code != "") printf "[deploy-git] Site responded with HTTP %s\n", code }' /tmp/goodwalk-site-check.$$ else echo "[deploy-git] WARNING: production site check failed for $VERIFY_URL" >&2 fi rm -f /tmp/goodwalk-site-check.$$ return 0 fi echo "[deploy-git] WARNING: curl/wget not available; skipping site check" >&2 } trap cleanup EXIT echo "[deploy-git] Deploying main Goodwalk stack from Git" echo "[deploy-git] Repo URL: $REPO_URL" echo "[deploy-git] Branch: $BRANCH" if [[ -n "$REF" ]]; then echo "[deploy-git] Requested ref: $REF" fi echo "[deploy-git] Target deployment path: $DEPLOY_PATH" echo "[deploy-git] Compose file: $COMPOSE_FILE" echo "[deploy-git] Docker project: $PROJECT_NAME" if [[ -n "$SERVICE_NAME" ]]; then echo "[deploy-git] Target service: $SERVICE_NAME" fi if (( nginx_args_present )); then echo "[deploy-git] Nginx config source: $NGINX_SOURCE" echo "[deploy-git] Nginx config target: $NGINX_TARGET" echo "[deploy-git] Nginx compose file: $NGINX_COMPOSE_FILE" echo "[deploy-git] Nginx project: $NGINX_PROJECT_NAME" echo "[deploy-git] Maintenance host dir: $MAINTENANCE_HOST_DIR" echo "[deploy-git] Maintenance flag path: $MAINTENANCE_FLAG_PATH" fi echo "[deploy-git] Cloning repository into: $CHECKOUT_DIR" git clone "$REPO_URL" "$CHECKOUT_DIR" git -C "$CHECKOUT_DIR" fetch --tags --prune origin if [[ -n "$REF" ]]; then git -C "$CHECKOUT_DIR" checkout --detach "$REF" else git -C "$CHECKOUT_DIR" checkout -B "$BRANCH" "origin/$BRANCH" fi DEPLOYED_REVISION="$(git -C "$CHECKOUT_DIR" rev-parse HEAD)" echo "[deploy-git] Using repo revision: $DEPLOYED_REVISION" EXPORT_SCRIPT="$CHECKOUT_DIR/scripts/export-homepage-content.mjs" [[ -f "$EXPORT_SCRIPT" ]] || fail "Homepage export script not found: $EXPORT_SCRIPT" echo "[deploy-git] Exporting current homepage content for PostgreSQL sync" run_homepage_export "/app/scripts/export-homepage-content.mjs" "/app/deploy-data/homepage-content.json" echo "[deploy-git] Preparing deployment payload" copy_checkout_to_payload [[ -f "$PAYLOAD_DIR/$COMPOSE_FILE" ]] || fail "Compose file missing from repo checkout: $COMPOSE_FILE" if [[ -f "$DEPLOY_PATH/.env" ]]; then echo "[deploy-git] Preserving existing $DEPLOY_PATH/.env" fi echo "[deploy-git] Copying application files into $DEPLOY_PATH" copy_payload_to_deploy [[ -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-git] 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 present" fi fi merge_env_file "$DEPLOY_PATH/deploy.env.template" "$DEPLOY_PATH/.env" cd "$DEPLOY_PATH" echo "[deploy-git] 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" NGINX_CID="$(docker ps -qf name=^nginx$ | head -n1 || true)" [[ -n "$NGINX_CID" ]] || fail "Shared nginx container is not running (expected name 'nginx'). Bring it up before deploying." if ! docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$NGINX_CID" \ | grep -Fxq "${MAINTENANCE_HOST_DIR}|/var/www/maintenance"; then fail "nginx container is missing the maintenance bind mount. Expected: ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro One-time setup on the droplet: mkdir -p ${MAINTENANCE_HOST_DIR}/m # add this volume to ${NGINX_COMPOSE_FILE}: # - ${MAINTENANCE_HOST_DIR}:/var/www/maintenance:ro ${COMPOSE_CMD[*]} -p ${NGINX_PROJECT_NAME} -f ${NGINX_COMPOSE_FILE} up -d" fi FLAG_DIR="$(dirname "$MAINTENANCE_FLAG_PATH")" [[ -d "$FLAG_DIR" ]] || fail "Maintenance flag directory does not exist on host: $FLAG_DIR" echo "[deploy-git] Writing maintenance assets to host bind dir: $MAINTENANCE_HOST_DIR" mkdir -p "$MAINTENANCE_HOST_DIR/m" install -m 0644 "$MAINTENANCE_HTML_SRC" "$MAINTENANCE_HOST_DIR/maintenance.html" install -m 0644 "$MAINTENANCE_LOGO_SRC" "$MAINTENANCE_HOST_DIR/m/logo.png" echo "[deploy-git] 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-git] Validating nginx configuration" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t echo "[deploy-git] 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-git] Engaging maintenance page via host flag: $MAINTENANCE_FLAG_PATH" : > "$MAINTENANCE_FLAG_PATH" MAINTENANCE_ACTIVE=1 fi if [[ -n "$SERVICE_NAME" ]]; then echo "[deploy-git] Stopping only the Goodwalk service: $SERVICE_NAME" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop "$SERVICE_NAME" || true echo "[deploy-git] 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-git] Stopping only the Goodwalk project containers" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" stop || true echo "[deploy-git] Rebuilding and starting only the Goodwalk project containers" "${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --build --remove-orphans fi echo "[deploy-git] 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-git] 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 check_site echo "[deploy-git] Remote deployment finished" echo "[deploy-git] Deployed revision: $DEPLOYED_REVISION"