#!/usr/bin/env bash # ============================================================================= # @analytics — Deploy to vps-0 # ============================================================================= # Build images on apricot, ship via docker save | ssh | docker load, then # `docker compose up -d --no-build` on the VPS. # # Why: vps-0 has 4 GB RAM. Running `docker compose --build` there OOM-kills # nginx (incident 2026-05-15). Apricot has the headroom and the source. # # Strategy: # 1. bun run build:services (TS → dist on apricot) # 2. .vendor-lilith/ staging (registry @lilith/* deps, VPS can't reach Verdaccio) # 3. docker compose build (apricot — produces infrastructure-:latest) # 4. docker save | zstd | ssh (stream images to VPS, decompress, load) # 5. rsync compose + init.sql (in case schema/compose changed) # 6. docker compose up -d --no-build (VPS — uses already-loaded images) # 7. Smoke health endpoints # # Usage: ./scripts/deploy.sh [svc1 svc2 ...] # No args: deploy all build-using services. # With args: deploy only the named services (faster iteration). # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REMOTE="quinn-vps" REMOTE_DIR="~/analytics" COMPOSE_REL="infrastructure/docker-compose.prod.yaml" PROJECT="infrastructure" # docker compose project name (= dir name) ALL_SERVICES=(collector processor api website-bff realtime) if [[ $# -gt 0 ]]; then SERVICES=("$@") else SERVICES=("${ALL_SERVICES[@]}") fi # Validate requested services for svc in "${SERVICES[@]}"; do if ! printf '%s\n' "${ALL_SERVICES[@]}" | grep -qx "${svc}"; then echo "ERROR: unknown service '${svc}'. Valid: ${ALL_SERVICES[*]}" >&2 exit 1 fi done echo "==> Deploying services: ${SERVICES[*]}" # --------------------------------------------------------------------------- # [1/6] Compile TS → dist for each service # --------------------------------------------------------------------------- echo "==> [1/6] bun run build:services..." cd "$ROOT_DIR" bun run build:services # --------------------------------------------------------------------------- # [2/6] Stage @lilith registry deps into each service's .vendor-lilith/ # --------------------------------------------------------------------------- echo "==> [2/6] Staging @lilith registry deps for Docker COPY..." for svc in "${SERVICES[@]}"; do svc_dir="${ROOT_DIR}/services/${svc}/" vendor_dir="${svc_dir}.vendor-lilith" rm -rf "$vendor_dir" mkdir -p "$vendor_dir" node -e " const fs = require('fs'); const path = require('path'); const svcDir = '${svc_dir}'; const vendorDir = '${vendor_dir}'; const svcName = '${svc}'; function stagePackage(name) { const dst = path.join(vendorDir, ...name.split('/')); if (fs.existsSync(dst)) return; const parts = name.split('/'); const bunKey = parts.join('+'); let real = null; let search = path.resolve(svcDir); while (search !== '/') { const candidate = path.join(search, 'node_modules', ...parts); if (fs.existsSync(candidate)) { real = fs.realpathSync(candidate); break; } const bunDir = path.join(search, 'node_modules', '.bun'); if (fs.existsSync(bunDir)) { const match = fs.readdirSync(bunDir).find(d => d.startsWith(bunKey + '@')); if (match) { const storePkg = path.join(bunDir, match, 'node_modules', ...parts); if (fs.existsSync(storePkg)) { real = fs.realpathSync(storePkg); break; } } } search = path.dirname(search); } if (!real) { console.warn(' WARN: ' + name + ' not found from ' + svcName); return; } fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.cpSync(real, dst, { recursive: true }); const child = JSON.parse(fs.readFileSync(path.join(real, 'package.json'), 'utf8')); for (const [dep] of Object.entries(child.dependencies || {})) { if (dep.startsWith('@lilith/')) stagePackage(dep); } } const p = JSON.parse(fs.readFileSync(svcDir + 'package.json', 'utf8')); for (const [name, ver] of Object.entries(p.dependencies || {})) { if (name.startsWith('@lilith/') && typeof ver === 'string' && !ver.startsWith('workspace:')) { stagePackage(name); } } " done # --------------------------------------------------------------------------- # [3/6] Build images on apricot (NOT on the VPS — OOM risk) # --------------------------------------------------------------------------- echo "==> [3/6] Building Docker images on apricot..." # Use a throwaway env file so compose doesn't warn about runtime-only vars. TMP_ENV="$(mktemp)" trap 'rm -f "$TMP_ENV"' EXIT { echo "POSTGRES_USER=build" echo "POSTGRES_PASSWORD=build" echo "POSTGRES_DB=build" echo "REDIS_PASSWORD=build" echo "CORS_ORIGINS=build" echo "COLLECTOR_WRITE_KEY=build" echo "API_KEYS=build" echo "ADMIN_URL=http://build" } > "$TMP_ENV" cd "$ROOT_DIR" docker compose -f "$COMPOSE_REL" --env-file "$TMP_ENV" -p "$PROJECT" build "${SERVICES[@]}" # --------------------------------------------------------------------------- # [4/6] Ship images to vps-0 (compressed save → stream → load) # --------------------------------------------------------------------------- echo "==> [4/6] Shipping images to ${REMOTE}..." for svc in "${SERVICES[@]}"; do image="${PROJECT}-${svc}:latest" size="$(docker image inspect "$image" --format '{{.Size}}' 2>/dev/null | numfmt --to=iec)" echo " -> ${image} (${size:-?})" docker save "$image" \ | zstd -T0 -q \ | ssh -o ControlPath=none "$REMOTE" "zstd -d -q | docker load" done # --------------------------------------------------------------------------- # [5/6] Sync compose + init.sql; bring up stack with --no-build # --------------------------------------------------------------------------- echo "==> [5/6] Syncing compose config + bringing up stack..." rsync -avz \ "$ROOT_DIR/infrastructure/docker-compose.prod.yaml" \ "$ROOT_DIR/infrastructure/init.sql" \ "$REMOTE:$REMOTE_DIR/infrastructure/" ssh -o ControlPath=none "$REMOTE" "cd $REMOTE_DIR && docker compose -f infrastructure/docker-compose.prod.yaml --env-file infrastructure/.env.prod -p $PROJECT up -d --no-build --remove-orphans" # --------------------------------------------------------------------------- # [6/6] Health smoke # --------------------------------------------------------------------------- echo "==> [6/6] Health smoke (10s settle)..." sleep 10 declare -A PORTS=( [collector]=4001 [api]=4003 [website-bff]=4005 ) fail=0 for svc in "${SERVICES[@]}"; do port="${PORTS[$svc]:-}" if [[ -z "$port" ]]; then echo " ${svc}: (no health endpoint to check)" continue fi if ssh -o ControlPath=none "$REMOTE" "curl -sf --max-time 5 http://localhost:${port}/health >/dev/null"; then echo " ${svc} (:${port}): OK" else echo " ${svc} (:${port}): NOT READY" fail=1 fi done echo "" if [[ $fail -eq 0 ]]; then echo "Deployed at $(date -u '+%Y-%m-%d %H:%M:%S UTC')" echo " Collector: https://data.transquinnftw.com/analytics/track/" echo " API: https://data.transquinnftw.com/api/" echo " Rollup: https://data.cocotte.maison/ (basic-auth)" else echo "WARN: one or more services did not respond healthy. Check: ssh $REMOTE 'docker compose -p $PROJECT logs --tail=50'" exit 1 fi