#!/usr/bin/env bash # # One-time data move: OLD (.cloud) -> NEW (.de). Run this FROM THE DEV LAPTOP # (Linux / macOS / WSL), not on a server. It: # 1. pre-flight checks both stacks are reachable and the DB containers are up, # 2. streams a full-cluster pg_dumpall from OLD straight into NEW (psql), # 3. rsyncs ${SUPABASE_DIR}/volumes/storage from OLD to NEW. # # Usage: # ./scripts/migrate/02-migrate-data.sh # interactive, asks to confirm # ./scripts/migrate/02-migrate-data.sh --check # pre-flight only, no changes # FORCE=1 ./scripts/migrate/02-migrate-data.sh # skip the confirm prompt # # BEFORE running: put the OLD app into maintenance / freeze writes, and make # sure ${SUPABASE_DIR}/.env on NEW already has the SAME POSTGRES_PASSWORD and # JWT_SECRET as OLD, and that NEW's db container has been started once so the # Supabase init scripts created the roles (see bootstrap NEXT STEPS step 4). set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${here}/config.sh" require_new_host mode="${1:-}" log() { echo "==> $*"; } # --- pre-flight ------------------------------------------------------------ log "pre-flight: checking SSH reachability" old_remote 'echo ok' >/dev/null || { echo "cannot ssh to OLD (${OLD_SSH})" >&2; exit 1; } new_remote 'echo ok' >/dev/null || { echo "cannot ssh to NEW (${NEW_SSH})" >&2; exit 1; } log "pre-flight: checking OLD Supabase db is up" old_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" \ || { echo "OLD db not ready — start the stack first" >&2; exit 1; } log "pre-flight: checking NEW Supabase db is up (must be initialized once)" new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" \ || { echo "NEW db not ready — run 'docker compose up -d db' on NEW first" >&2; exit 1; } log "pre-flight: checking NEW storage volume dir exists" new_remote "test -d ${SUPABASE_DIR}/volumes/storage || mkdir -p ${SUPABASE_DIR}/volumes/storage" if [[ "${mode}" == "--check" ]]; then log "pre-flight OK — --check requested, stopping before any changes." exit 0 fi # --- loud confirm ---------------------------------------------------------- cat < NEW ${NEW_SSH} ---------------------------------------------------------------------------- This will: * pg_dumpall the WHOLE OLD cluster and restore it into the NEW db (DROP/CREATE objects on NEW via --clean --if-exists), * rsync ${SUPABASE_DIR}/volumes/storage OLD -> NEW with --delete (the NEW storage dir becomes an EXACT mirror of OLD). MAKE SURE FIRST: * the OLD app is in MAINTENANCE / writes are FROZEN (no new uploads, no new rows) so DB + storage stay consistent, * NEW /opt/supabase/.env already has the OLD POSTGRES_PASSWORD + JWT_SECRET, * you have a backup / you can roll DNS back to the OLD VPS. ---------------------------------------------------------------------------- EOF if [[ "${FORCE:-0}" != "1" ]]; then read -rp "Type 'migrate' to proceed: " confirm if [[ "${confirm}" != "migrate" ]]; then echo "aborted — nothing changed." exit 1 fi fi # --- 1) Postgres: full-cluster dump OLD -> restore NEW --------------------- # pg_dumpall (not pg_dump) carries the ROLE definitions + password hashes, so # with an identical POSTGRES_PASSWORD on both hosts the restored roles line up # with what the services use. ON_ERROR_STOP=0 because pg_dumpall will try to # CREATE ROLE supabase_admin/postgres etc. that already exist on the freshly # initialized NEW cluster — those 'already exists' errors are harmless. # # ALTERNATIVE (highest fidelity): if both servers run the SAME Postgres image # tag, a cold volume copy avoids logical-restore-over-initialized-cluster # fragility entirely: stop both DB containers, rsync ${SUPABASE_DIR}/volumes/db # OLD -> NEW, start both again. Use that if the scan below keeps flagging errors. log "dumping OLD cluster and restoring into NEW (streamed over SSH)" log "this can take a while; harmless 'already exists' errors are expected." restore_log="$(mktemp)" set +e old_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_dumpall -U postgres --clean --if-exists" \ | new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=0" \ 2>&1 | tee "${restore_log}" set -e # ON_ERROR_STOP=0 keeps the restore going past harmless 'already exists', but it # ALSO swallows genuine failures (FK/constraint/ownership/extension errors) # that would leave a partially-restored DB looking successful. Surface any # non-benign ERROR/FATAL/PANIC and refuse to continue to the storage rsync. real_errors="$(grep -E 'ERROR:|FATAL:|PANIC:' "${restore_log}" 2>/dev/null \ | grep -Eiv 'already exists|cannot drop the currently open database|is being accessed by other users|must be member of role|role .* cannot be dropped|current transaction is aborted' \ || true)" if [[ -n "${real_errors}" ]]; then echo >&2 echo "!!! Non-benign errors during restore (full log: ${restore_log}):" >&2 echo "${real_errors}" | head -n 50 >&2 if [[ "${FORCE_RESTORE_OK:-0}" != "1" ]]; then echo "Aborting BEFORE the storage rsync. Inspect/fix and re-run, or consider" >&2 echo "the cold volume-copy path. Override with FORCE_RESTORE_OK=1 only if you" >&2 echo "are certain these are harmless." >&2 exit 1 fi log "FORCE_RESTORE_OK=1 — continuing despite the errors above." else log "restore output scanned: no non-benign errors found." fi log "DO NOT run push-migrations.sh: all migrations are already in the dump." # --- 2) Storage objects: rsync OLD -> NEW ---------------------------------- # Object bytes live on the bind-mounted volume; their metadata rows came with # the dump above. -aHAX keeps perms/hardlinks/ACLs/xattrs; --delete makes NEW an # exact mirror (safe only because writes are frozen). Trailing slashes matter. log "rsyncing storage volume OLD -> NEW (server-to-server via SSH)" if old_remote "command -v rsync >/dev/null 2>&1"; then # Direct server-to-server: the OLD host pushes to NEW. Needs the OLD host to # be able to ssh to NEW (key in OLD ~/.ssh, NEW in known_hosts). old_remote "sudo rsync -aHAX --numeric-ids --delete \ -e 'ssh -o StrictHostKeyChecking=accept-new' \ ${SUPABASE_DIR}/volumes/storage/ ${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/" \ || { log "direct server-to-server rsync failed — falling back to two-hop via laptop" stage="$(mktemp -d)" log "staging into ${stage}" # shellcheck disable=SC2086 rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \ "${OLD_SSH}:${SUPABASE_DIR}/volumes/storage/" "${stage}/" # shellcheck disable=SC2086 rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" \ "${stage}/" "${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/" rm -rf "${stage}" } else log "rsync missing on OLD — using two-hop via laptop" stage="$(mktemp -d)" log "staging into ${stage}" # shellcheck disable=SC2086 rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \ "${OLD_SSH}:${SUPABASE_DIR}/volumes/storage/" "${stage}/" # shellcheck disable=SC2086 rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" \ "${stage}/" "${NEW_SSH}:${SUPABASE_DIR}/volumes/storage/" rm -rf "${stage}" fi # --- 3) Restart NEW stack so every service reconnects to the new data ------ log "restarting the NEW Supabase stack (down + up -d)" new_remote "cd ${SUPABASE_DIR} && docker compose down && docker compose up -d" # --- 4) Row-count parity check OLD vs NEW (load-bearing tables) ------------- # A users/objects-only check can miss partial loss in messages/members/etc., # so compare the tables the app actually depends on. Non-fatal (table names can # legitimately vary), but a mismatch on auth.users / public.messages is a red # flag — do NOT cut over until it is understood. log "waiting for NEW db to accept connections, then checking row-count parity" for _ in $(seq 1 30); do new_remote "cd ${SUPABASE_DIR} && docker compose exec -T db pg_isready -U postgres" >/dev/null 2>&1 && break sleep 2 done count_on() { # $1=old|new $2=table local q="select count(*) from $2;" local runner=old_remote [[ "$1" == "new" ]] && runner=new_remote "${runner}" "cd ${SUPABASE_DIR} && docker compose exec -T db psql -U postgres -d postgres -tAc \"${q}\"" 2>/dev/null | tr -d '[:space:]' } parity_fail=0 for t in auth.users auth.identities public.profiles public.messages \ public.conversation_members storage.objects; do o="$(count_on old "$t" 2>/dev/null || echo '?')" n="$(count_on new "$t" 2>/dev/null || echo '?')" if [[ -n "$o" && "$o" == "$n" ]]; then log " OK ${t}: ${o}" else log " MISMATCH ${t}: OLD=${o:-?} NEW=${n:-?}" parity_fail=1 fi done [[ "${parity_fail}" == "1" ]] && log "⚠ row-count mismatch — investigate BEFORE cutover." cat <