diff --git a/nginx/goodwalk.co.nz.svelte.conf.example b/nginx/goodwalk.co.nz.svelte.conf.example
index e509f4d..77192db 100644
--- a/nginx/goodwalk.co.nz.svelte.conf.example
+++ b/nginx/goodwalk.co.nz.svelte.conf.example
@@ -53,6 +53,26 @@ server {
# nginx does not keep stale upstream IPs in memory.
resolver 127.0.0.11 ipv6=off valid=30s;
+ # Maintenance mode: when /etc/nginx/conf.d/maintenance.flag exists,
+ # serve the static "be right back" page with a 503 status. The flag is
+ # toggled by the deploy script (touch / rm) without reloading nginx.
+ error_page 503 /maintenance.html;
+
+ location = /maintenance.html {
+ root /var/www/html;
+ internal;
+ add_header Cache-Control "no-store" always;
+ }
+
+ # Static assets used only by the maintenance page (logo, etc.). Served
+ # directly from the nginx html mount so they remain reachable while the
+ # SvelteKit app is down.
+ location /m/ {
+ root /var/www/html;
+ access_log off;
+ add_header Cache-Control "public, max-age=3600" always;
+ }
+
location ~* /\.(git|env|htaccess) {
deny all;
}
@@ -66,6 +86,10 @@ server {
}
location /api/submit {
+ if (-f /etc/nginx/conf.d/maintenance.flag) {
+ return 503;
+ }
+
set $goodwalk_mail_api goodwalk_svelte_mail_api:8000;
limit_req zone=goodwalk_limit burst=10 nodelay;
proxy_pass http://$goodwalk_mail_api/submit;
@@ -77,6 +101,10 @@ server {
}
location / {
+ if (-f /etc/nginx/conf.d/maintenance.flag) {
+ return 503;
+ }
+
set $goodwalk_frontend goodwalk_svelte_app:3000;
proxy_pass http://$goodwalk_frontend;
proxy_http_version 1.1;
diff --git a/nginx/logo.png b/nginx/logo.png
new file mode 100644
index 0000000..f3b19a5
Binary files /dev/null and b/nginx/logo.png differ
diff --git a/nginx/maintenance.html b/nginx/maintenance.html
new file mode 100644
index 0000000..349392b
--- /dev/null
+++ b/nginx/maintenance.html
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+ Be right back | Goodwalk
+
+
+
+
+
+

+
+
+
+
+
+
+
+ Goodwalk
+ Be right back!
+ We're updating the site — should only take a minute. Thanks for your patience.
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh
index 858e584..926f522 100644
--- a/scripts/deploy-remote.sh
+++ b/scripts/deploy-remote.sh
@@ -114,8 +114,19 @@ else
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"
}
@@ -182,6 +193,70 @@ if [[ ! -f "$DEPLOY_PATH/.env" ]]; then
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"
@@ -194,6 +269,39 @@ if [[ -n "$SERVICE_NAME" ]]; then
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
@@ -216,19 +324,6 @@ if [[ -z "$SERVICE_NAME" || "$SERVICE_NAME" == "app" || "$SERVICE_NAME" == "db"
"${COMPOSE_CMD[@]}" -p "$PROJECT_NAME" -f "$COMPOSE_FILE" exec -T app node scripts/sync-homepage-content.mjs
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"
-
- echo "[deploy-remote] Updating shared nginx config to avoid stale container IPs"
- 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"
- "${COMPOSE_CMD[@]}" -p "$NGINX_PROJECT_NAME" -f "$NGINX_COMPOSE_FILE" exec -T nginx nginx -s reload
-fi
+clear_maintenance_flag
echo "[deploy-remote] Remote deployment finished"
diff --git a/src/lib/components/ServicesSection.svelte b/src/lib/components/ServicesSection.svelte
index f320203..b9784e9 100644
--- a/src/lib/components/ServicesSection.svelte
+++ b/src/lib/components/ServicesSection.svelte
@@ -25,7 +25,9 @@
{/if}
{#if service.href}
- Learn more
+
+ Learn more about {service.title}
+
{/if}
{/each}
diff --git a/src/lib/styles/base.css b/src/lib/styles/base.css
index 0fb17c4..b6873f1 100644
--- a/src/lib/styles/base.css
+++ b/src/lib/styles/base.css
@@ -34,3 +34,15 @@ input,
textarea {
font: inherit;
}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}