#!/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="" MAINTENANCE_HOST_DIR="" MAINTENANCE_FLAG_PATH="" SEED_ADMIN_DATA=0 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 ] \ [--maintenance-host-dir ] [--maintenance-flag ] 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 } get_mount_source_for_destination() { local container_id="$1" local destination="$2" docker inspect -f '{{range .Mounts}}{{.Source}}|{{.Destination}}{{println}}{{end}}' "$container_id" \ | awk -F'|' -v wanted="$destination" '$2 == wanted { print $1; exit }' } write_acme_bootstrap_config() { local source_config="$1" local target_config="$2" shift 2 awk -v skip_domains="$*" ' BEGIN { split(skip_domains, items, " ") for (i in items) { if (items[i] != "") skip[items[i]] = 1 } in_server = 0 depth = 0 block = "" is_ssl = 0 has_skipped_domain = 0 } function emit_block() { if (!(is_ssl && has_skipped_domain)) { printf "%s", block } in_server = 0 depth = 0 block = "" is_ssl = 0 has_skipped_domain = 0 } { line = $0 ORS if (!in_server) { if ($0 ~ /^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/) { in_server = 1 depth = 1 block = line next } printf "%s", line next } block = block line if ($0 ~ /^[[:space:]]*listen[[:space:]]+443[[:space:]]+ssl([[:space:]]|;)/) { is_ssl = 1 } if ($0 ~ /^[[:space:]]*server_name[[:space:]]+/) { count = split($0, parts, /[[:space:];]+/) for (i = 2; i <= count; i++) { if (parts[i] in skip) { has_skipped_domain = 1 } } } opens = gsub(/\{/, "{", $0) closes = gsub(/\}/, "}", $0) depth += opens - closes if (depth == 0) { emit_block() } } END { if (in_server) { emit_block() } } ' "$source_config" > "$target_config" } obtain_certificate() { local domain="$1" local cert_root="$2" local acme_webroot="$3" local certbot_email="info@goodwalk.co.nz" echo "[deploy-remote] Obtaining TLS certificate for $domain" docker run --rm \ -v "$cert_root:/etc/letsencrypt" \ -v "$acme_webroot:/var/www/certbot" \ certbot/certbot:latest \ certonly \ --webroot \ -w /var/www/certbot \ --cert-name "$domain" \ -d "$domain" \ --non-interactive \ --agree-tos \ -m "$certbot_email" } 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 ;; --maintenance-host-dir) MAINTENANCE_HOST_DIR="${2:-}" shift 2 ;; --maintenance-flag) MAINTENANCE_FLAG_PATH="${2:-}" shift 2 ;; -h|--help) usage exit 0 ;; --seed-admin-data) SEED_ADMIN_DATA=1 shift 1 ;; *) 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" [[ -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 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 at $MAINTENANCE_FLAG_PATH" rm -f "$MAINTENANCE_FLAG_PATH" || 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" echo "[deploy-remote] Maintenance host dir: $MAINTENANCE_HOST_DIR" echo "[deploy-remote] Maintenance flag path: $MAINTENANCE_FLAG_PATH" 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" # Pre-flight: the nginx container must have a bind mount whose source is # MAINTENANCE_HOST_DIR and whose destination is /var/www/maintenance. If the # one-time droplet setup has not been done, fail fast with a clear message # rather than silently serving stale content. 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." CERT_ROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/etc/letsencrypt")" [[ -n "$CERT_ROOT_HOST_DIR" ]] || fail "nginx container is missing the certificate bind mount for /etc/letsencrypt." CERTBOT_WEBROOT_HOST_DIR="$(get_mount_source_for_destination "$NGINX_CID" "/var/www/certbot")" [[ -n "$CERTBOT_WEBROOT_HOST_DIR" ]] || fail "nginx container is missing the ACME webroot bind mount for /var/www/certbot." ONBOARDING_CERT="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/fullchain.pem" ONBOARDING_KEY="$CERT_ROOT_HOST_DIR/live/onboarding.goodwalk.co.nz/privkey.pem" CLIENTS_CERT="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/fullchain.pem" CLIENTS_KEY="$CERT_ROOT_HOST_DIR/live/clients.goodwalk.co.nz/privkey.pem" CP_CERT="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/fullchain.pem" CP_KEY="$CERT_ROOT_HOST_DIR/live/cp.goodwalk.co.nz/privkey.pem" MISSING_CERT_DOMAINS=() if [[ ! -f "$ONBOARDING_CERT" || ! -f "$ONBOARDING_KEY" ]]; then MISSING_CERT_DOMAINS+=("onboarding.goodwalk.co.nz") fi if [[ ! -f "$CLIENTS_CERT" || ! -f "$CLIENTS_KEY" ]]; then MISSING_CERT_DOMAINS+=("clients.goodwalk.co.nz") fi if [[ ! -f "$CP_CERT" || ! -f "$CP_KEY" ]]; then MISSING_CERT_DOMAINS+=("cp.goodwalk.co.nz") fi if (( ${#MISSING_CERT_DOMAINS[@]} > 0 )); then echo "[deploy-remote] Missing TLS certificates detected: ${MISSING_CERT_DOMAINS[*]}" echo "[deploy-remote] Bootstrapping nginx HTTP config so ACME challenges can be served" mkdir -p "$(dirname "$NGINX_TARGET")" BOOTSTRAP_CONFIG="$(mktemp "${TMPDIR:-/tmp}/goodwalk-nginx-acme.XXXXXX.conf")" write_acme_bootstrap_config "$DEPLOY_PATH/$NGINX_SOURCE" "$BOOTSTRAP_CONFIG" "${MISSING_CERT_DOMAINS[@]}" cp "$BOOTSTRAP_CONFIG" "$NGINX_TARGET" rm -f "$BOOTSTRAP_CONFIG" echo "[deploy-remote] Validating bootstrap nginx configuration" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -t echo "[deploy-remote] Reloading shared nginx with bootstrap config" "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload for domain in "${MISSING_CERT_DOMAINS[@]}"; do echo "[deploy-remote] Ensure the DNS A record for $domain points to this server before certificate issuance" obtain_certificate "$domain" "$CERT_ROOT_HOST_DIR" "$CERTBOT_WEBROOT_HOST_DIR" \ || fail "Automatic certificate issuance failed for $domain. Confirm DNS resolves here and port 80 is reachable." done [[ -f "$ONBOARDING_CERT" && -f "$ONBOARDING_KEY" ]] || fail "Automatic certificate issuance did not create onboarding.goodwalk.co.nz at $ONBOARDING_CERT" [[ -f "$CLIENTS_CERT" && -f "$CLIENTS_KEY" ]] || fail "Automatic certificate issuance did not create clients.goodwalk.co.nz at $CLIENTS_CERT" [[ -f "$CP_CERT" && -f "$CP_KEY" ]] || fail "Automatic certificate issuance did not create cp.goodwalk.co.nz at $CP_CERT" fi 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-remote] 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-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] 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 via host flag: $MAINTENANCE_FLAG_PATH" : > "$MAINTENANCE_FLAG_PATH" MAINTENANCE_ACTIVE=1 fi if [[ "$SEED_ADMIN_DATA" -eq 1 ]]; then echo "[deploy-remote] Admin data seed requested: mail-api will overwrite admin_kv from JSON on next boot" export ADMIN_DATA_SEED_FROM_JSON="force" else export ADMIN_DATA_SEED_FROM_JSON="auto" 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"