#!/usr/bin/env bash # Rolling deploy: drains each api container from nginx before replacing it, # so users see zero downtime. Job workers are restarted last without draining. # # Usage: # ./deploy.sh e.g. ./deploy.sh a1b2c3d # # Overridable via env vars (defaults below are gatehouse-api): # IMAGE_NAME docker image name # SVC1/SVC2 compose service names # SVC1_PORT host port for SVC1 # SVC2_PORT host port for SVC2 # HEALTH_PATH HTTP path for health check # NGINX_CONF path to nginx site config # COMPOSE_DIR directory with docker-compose.yml and .env # # The runner user needs passwordless sudo for nginx: # echo "runner ALL=(ALL) NOPASSWD: /usr/sbin/nginx, /bin/systemctl reload nginx" \ # | sudo tee /etc/sudoers.d/runner-nginx set -euo pipefail TAG="${1:?Usage: deploy.sh (e.g. deploy.sh a1b2c3d)}" IMAGE_NAME="${IMAGE_NAME:-gatehouse-api}" NGINX_CONF="${NGINX_CONF:-/etc/nginx/conf.d/gatehouse-api.conf}" COMPOSE_DIR="${COMPOSE_DIR:-/opt/gatehouse-api}" SVC1="${SVC1:-api1}" SVC2="${SVC2:-api2}" SVC1_PORT="${SVC1_PORT:-5000}" SVC2_PORT="${SVC2_PORT:-5001}" HEALTH_PATH="${HEALTH_PATH:-/api/health}" HEALTH_RETRIES=18 # 18 × 5 s = 90 s max HEALTH_INTERVAL=5 # ── helpers ─────────────────────────────────────────────────────────────────── log() { echo "[$(date '+%H:%M:%S')] $*"; } die() { log "ERROR: $*" >&2; exit 1; } step() { log ""; log "── $* ──"; } health_check() { local port=$1 label=$2 attempt=0 log "Waiting for ${label} on :${port}${HEALTH_PATH} ..." while (( attempt < HEALTH_RETRIES )); do if curl -sf "http://127.0.0.1:${port}${HEALTH_PATH}" -o /dev/null; then log "✓ ${label} healthy" return 0 fi attempt=$(( attempt + 1 )) log " [${attempt}/${HEALTH_RETRIES}] not ready, retrying in ${HEALTH_INTERVAL}s" sleep "${HEALTH_INTERVAL}" done log "ERROR: ${label} failed health check after $((HEALTH_RETRIES * HEALTH_INTERVAL))s" return 1 } get_service_tag() { docker compose ps -q "$1" 2>/dev/null \ | xargs -r docker inspect --format '{{.Config.Image}}' 2>/dev/null \ | cut -d: -f2 } rollback() { local service=$1 port=$2 old_tag=$3 if [[ -z "${old_tag}" ]]; then nginx_restore "${port}" die "Deploy aborted — ${service} failed health check, no previous tag to roll back to" fi log "Rolling back ${service} to ${old_tag}..." IMAGE_TAG="${old_tag}" docker compose up -d --no-deps --force-recreate "${service}" nginx_restore "${port}" die "Deploy aborted — ${service} rolled back to ${old_tag}" } nginx_drain() { local port=$1 sudo sed -i "s|server 127.0.0.1:${port};|server 127.0.0.1:${port} down;|" "$NGINX_CONF" sudo nginx -t 2>&1 | tail -2 sudo nginx -s reload log "nginx: drained :${port}" } nginx_restore() { local port=$1 sudo sed -i "s|server 127.0.0.1:${port} down;|server 127.0.0.1:${port};|" "$NGINX_CONF" sudo nginx -t 2>&1 | tail -2 sudo nginx -s reload log "nginx: restored :${port}" } # ── pre-flight ──────────────────────────────────────────────────────────────── cd "${COMPOSE_DIR}" pwd; ls -la log "Deploying ${IMAGE_NAME}:${TAG}" docker image inspect "${IMAGE_NAME}:${TAG}" > /dev/null 2>&1 \ || die "Image ${IMAGE_NAME}:${TAG} not found locally — build it first." # ── roll SVC1 ───────────────────────────────────────────────────────────────── step "${SVC1} → ${TAG} (traffic: ${SVC2} only)" old_svc1=$(get_service_tag "${SVC1}") nginx_drain "${SVC1_PORT}" log "Waiting 15s for in-flight requests to drain..." sleep 15 IMAGE_TAG="${TAG}" docker compose up -d --no-deps --force-recreate "${SVC1}" health_check "${SVC1_PORT}" "${SVC1}" || rollback "${SVC1}" "${SVC1_PORT}" "${old_svc1}" nginx_restore "${SVC1_PORT}" # ── roll SVC2 ───────────────────────────────────────────────────────────────── step "${SVC2} → ${TAG} (traffic: ${SVC1} only)" old_svc2=$(get_service_tag "${SVC2}") nginx_drain "${SVC2_PORT}" log "Waiting 15s for in-flight requests to drain..." sleep 15 IMAGE_TAG="${TAG}" docker compose up -d --no-deps --force-recreate "${SVC2}" health_check "${SVC2_PORT}" "${SVC2}" || rollback "${SVC2}" "${SVC2_PORT}" "${old_svc2}" nginx_restore "${SVC2_PORT}" # ── job workers ─────────────────────────────────────────────────────────────── step "job workers → ${TAG}" IMAGE_TAG="${TAG}" docker compose up -d --no-deps --force-recreate zerotier-reconciler mfa-compliance # ── done ────────────────────────────────────────────────────────────────────── log "" log "Deploy complete ✓ ${IMAGE_NAME}:${TAG}"