#!/usr/bin/env bash # Deploy the full stack to ambrosio. # # Usage: infra/scripts/deploy-erosi.sh # Env: DEPLOY_HOST=ambrosio (override if needed) # DEPLOY_PATH=/opt/colectivo # # First run: # 1. rsyncs repo → ambrosio:/opt/colectivo/ # 2. if .env doesn't exist on server, generates secrets and writes it with # placeholder values the operator must fill in (external Keycloak URL, # Keycloak client secret) # 3. docker compose build + up -d # 4. applies DB migrations # # Internal edge is a Caddy container listening on host port 3000 — the # external proxy (TLS terminator, off-stack) forwards plain HTTP to # ambrosio:3000. This script never touches the host reverse-proxy config — # it only manages the Docker stack. # # Subsequent runs: rsync + rebuild app + rolling restart. # # Pre-deploy safety (every run, before any code/migration changes): # * pg_dumpall to /opt/colectivo/backups/predeploy--.sql.gz # * Keep newest 10 backups, prune older. # * On success, append a line to /opt/colectivo/.deploys.log: # # The rollback script (infra/scripts/rollback-erosi.sh) reads .deploys.log # to pair (sha, backup) when rolling back code + DB together. set -euo pipefail DEPLOY_HOST="${DEPLOY_HOST:-ambrosio}" DEPLOY_PATH="${DEPLOY_PATH:-/opt/colectivo}" REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" # Fase 14.3.5 — capture the short git sha on the deploy host (this # machine) so it can be baked into the built app bundle as # `__APP_COMMIT__`. We do it here rather than on ambrosio because we # rsync without `.git`, so the remote can't run `git rev-parse`. GIT_SHA="$(cd "$REPO_ROOT" && git rev-parse --short HEAD 2>/dev/null || echo dev)" # Fase 17.3 — capture the exact-match tag on HEAD (if any). Empty # string when HEAD is between tags. Warn (don't block) when missing # so untaggable hotfix deploys still go through, and stamp the tag # into .deploys.log so the rollback script can surface it. GIT_TAG="$(cd "$REPO_ROOT" && git describe --exact-match --tags HEAD 2>/dev/null || true)" if [ -z "$GIT_TAG" ]; then # ANSI yellow — visible enough to read past a terminal full of # rsync output, but no exit. Sleep gives the operator a 5s window # to Ctrl-C and tag HEAD before the rsync starts. printf '\033[33mWARNING:\033[0m HEAD is not at a tagged commit. Deploys without a tag\n' printf ' won'\''t map cleanly to CHANGELOG entries. Continue? [5s abort]\n' sleep 5 fi if [ -n "$GIT_TAG" ]; then echo "==> Deploying to $DEPLOY_HOST:$DEPLOY_PATH (commit $GIT_SHA, tag $GIT_TAG)" else echo "==> Deploying to $DEPLOY_HOST:$DEPLOY_PATH (commit $GIT_SHA, no tag)" fi # ── 1. Ensure target dir exists ──────────────────────────────────────────── ssh "$DEPLOY_HOST" "sudo mkdir -p $DEPLOY_PATH && sudo chown \$(id -u):\$(id -g) $DEPLOY_PATH" # ── 2. rsync repo (excluding node_modules / build artefacts / local env) ── rsync -az --delete \ --exclude '.git' \ --exclude 'node_modules' \ --exclude '.svelte-kit' \ --exclude 'apps/web/build' \ --exclude 'apps/web/.vite' \ --exclude '**/*.log' \ --exclude '**/.env' \ --exclude '**/.env.development' \ --exclude '**/.env.local' \ --exclude 'lighthouse-report.html' \ --exclude 'coverage' \ --exclude 'test-results' \ --exclude 'playwright-report' \ "$REPO_ROOT/" "$DEPLOY_HOST:$DEPLOY_PATH/" # ── 3. Bootstrap secrets on first run; sanity-check placeholders every run ─ ssh "$DEPLOY_HOST" bash -s << 'REMOTE' set -euo pipefail cd /opt/colectivo if [ ! -f .env ]; then echo "--- First deploy: generating secrets" # JWT triplet (anon + service_role) JWT_OUT=$(bash infra/scripts/rotate-jwt.sh) SUPABASE_JWT_SECRET=$(echo "$JWT_OUT" | awk -F= '/^SUPABASE_JWT_SECRET=/{print substr($0, index($0,$2))}') PUBLIC_SUPABASE_ANON_KEY=$(echo "$JWT_OUT" | awk -F= '/^PUBLIC_SUPABASE_ANON_KEY=/{print substr($0, index($0,$2))}') SUPABASE_SERVICE_ROLE_KEY=$(echo "$JWT_OUT" | awk -F= '/^SUPABASE_SERVICE_ROLE_KEY=/{print substr($0, index($0,$2))}') # Postgres + Realtime secrets POSTGRES_PASSWORD=$(openssl rand -hex 24) REALTIME_ENC_KEY=$(openssl rand -hex 8) # 16 chars REALTIME_SECRET_KEY_BASE=$(openssl rand -hex 48) # 96 chars cat > .env <&2 echo " Fill in PUBLIC_KEYCLOAK_URL, KEYCLOAK_CLIENT_SECRET and retry." >&2 exit 1 fi REMOTE # ── 3b. Pre-deploy DB backup ────────────────────────────────────────────── # Always taken BEFORE the new code/migrations land, so a paired rollback # can restore both. Stored on ambrosio at /opt/colectivo/backups/. Keep # newest 10; older ones get pruned. ssh "$DEPLOY_HOST" env GIT_SHA="$GIT_SHA" bash -s << 'REMOTE' set -euo pipefail cd /opt/colectivo : "${GIT_SHA:=dev}" mkdir -p backups BACKUP_TS=$(date -u +"%Y%m%dT%H%M%SZ") BACKUP_FILE="backups/predeploy-${BACKUP_TS}-${GIT_SHA}.sql.gz" echo "--- Pre-deploy backup → $BACKUP_FILE" docker compose --env-file .env -f infra/docker-compose.erosi.yml exec -T db \ pg_dumpall -U postgres --clean --if-exists "$BACKUP_FILE" size=$(stat -c%s "$BACKUP_FILE") if [ "$size" -lt 1024 ]; then echo "ERROR: backup is suspiciously small ($size bytes). Aborting deploy." >&2 rm -f "$BACKUP_FILE" exit 1 fi echo " ok ($(numfmt --to=iec --suffix=B "$size" 2>/dev/null || echo "$size bytes"))" # Prune: keep newest 10 predeploy backups. ls -1t backups/predeploy-*.sql.gz 2>/dev/null | tail -n +11 | xargs -r rm -f # Stash filename for step 4 to record once everything succeeds. echo "$BACKUP_FILE" > /tmp/.colectivo-last-backup REMOTE # ── 4. Build + bring up the stack ───────────────────────────────────────── # We forward GIT_SHA over the ssh env so the remote `docker compose build` # can substitute it into the Dockerfile build-arg (compose reads it from # the same shell, not from .env, so a transient export is enough). ssh # doesn't accept VAR=value args directly, so we prefix `env`. # # Fase 17.3.2: also forward GIT_TAG so the deploy log gets a 4th tab- # separated column (empty string when HEAD is not at a tag — preserves # the 3-column compat the rollback script's `cut -f1,2,3` already reads). ssh "$DEPLOY_HOST" env GIT_SHA="$GIT_SHA" GIT_TAG="${GIT_TAG:-}" bash -s << 'REMOTE' set -euo pipefail cd /opt/colectivo : "${GIT_SHA:=dev}" : "${GIT_TAG:=}" echo "--- Building app image (commit $GIT_SHA)" GIT_SHA="$GIT_SHA" docker compose --env-file .env -f infra/docker-compose.erosi.yml \ build --build-arg GIT_SHA="$GIT_SHA" app echo "--- Bringing stack up (detached)" GIT_SHA="$GIT_SHA" docker compose --env-file .env -f infra/docker-compose.erosi.yml up -d echo "--- Waiting for db to be healthy" for i in $(seq 1 60); do if docker compose --env-file .env -f infra/docker-compose.erosi.yml ps db --format json | grep -q '"Health":"healthy"'; then echo "db healthy"; break fi sleep 2 done echo "--- Applying migrations" # Every `docker compose exec -T` call below pipes in `\t\t\t # The 4th column is Fase 17 — older rows have 3 columns; the rollback # script's `cut -f4` returns empty for them, which is the right # "no tag" sentinel. backup_file=$(cat /tmp/.colectivo-last-backup 2>/dev/null || echo "") deploy_ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") printf '%s\t%s\t%s\t%s\n' "$deploy_ts" "$GIT_SHA" "$backup_file" "$GIT_TAG" \ >> /opt/colectivo/.deploys.log rm -f /tmp/.colectivo-last-backup echo "--- Recorded deploy in /opt/colectivo/.deploys.log" REMOTE echo "==> Deploy complete." echo " App: https://erosi.limonia.net" echo " Keycloak: external — see PUBLIC_KEYCLOAK_URL in $DEPLOY_PATH/.env"