0eed557f95
Make the production deploy foolproof against the shared nginx container's read-only bind mounts on the Digital Ocean droplet. The previous maintenance flow tried to docker-cp/docker-exec into /var/www/html and /etc/nginx/conf.d, both of which are mounted :ro on prod, and /var/www/html happens to point at the WordPress html dir — so writes either failed silently or risked scribbling into another site's tree. Maintenance assets and the engagement flag are now written directly to host paths (/docker/nginx/maintenance and /docker/nginx/conf.d/maintenance.flag) that nginx already sees through its existing bind mounts, so the script no longer depends on a writable container layer, survives container rebuilds, and works regardless of read_only settings. A pre-flight check verifies the maintenance bind mount is actually present on the nginx container and fails fast with a clear "run the one-time setup" message if it isn't, instead of silently serving stale content. The nginx config now serves maintenance.html and /m/ from a dedicated /var/www/maintenance root rather than sharing the WordPress html dir. On the front end, hero images on Pack Walks, 1:1 Walks and Puppy Visits were rendering at whatever aspect ratio their source files happened to have, so one page felt tall, another wide, another oversized. They are now locked to a 4:3 frame with object-fit: cover, matching the About Us section images, which were given the same treatment. The About Us body grid was also alternating between 0.7fr/1.3fr and 1.3fr/0.7fr columns depending on whether a section was reversed, which made the copy width jump between sections; both layouts are now an even 50/50 split, with the existing order swap still handling the image-left vs image-right alternation. The reveal-on-scroll action used to require 18% of an element to intersect before fading it in, with an additional -8% bottom margin, which meant the section directly below a service-page hero stayed invisible on initial load until the user scrolled — making the page look blank below the hero on navigation. The action now does a synchronous bounding-rect check on mount and reveals anything already in the viewport immediately, falling back to the IntersectionObserver for everything below the fold. The "Explore our services" block on About Us was a bespoke icon-tile grid that did not match the homepage's "What we do" cards; it now reuses the shared ServicesSection component (with the heading exposed as a prop), so both pages produce identical card layout, descriptions, "from $" prices, and Learn more CTAs. The footer Explore column was missing the About Us link — added between Our Pricing and Contact Us so it propagates through the homepage content sync into PostgreSQL on the next deploy. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
361 lines
12 KiB
Bash
361 lines
12 KiB
Bash
#!/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=""
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage:
|
|
deploy-remote.sh --archive <path> --deploy-path <path> --compose-file <name> --project-name <name>
|
|
deploy-remote.sh --archive <path> --deploy-path <path> --compose-file <name> --project-name <name> [--service <name>]
|
|
deploy-remote.sh --archive <path> --deploy-path <path> --compose-file <name> --project-name <name> \
|
|
[--service <name>] [--nginx-source <path>] [--nginx-target <path>] \
|
|
[--nginx-compose-file <path>] [--nginx-project-name <name>] \
|
|
[--maintenance-host-dir <path>] [--maintenance-flag <path>]
|
|
|
|
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
|
|
;;
|
|
--maintenance-host-dir)
|
|
MAINTENANCE_HOST_DIR="${2:-}"
|
|
shift 2
|
|
;;
|
|
--maintenance-flag)
|
|
MAINTENANCE_FLAG_PATH="${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"
|
|
[[ -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."
|
|
|
|
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 [[ -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"
|