5bc30c950c
Move Supabase + LiveKit from the netralax.cloud VPS to a new netralax.de server. Adds the migration runbook (docs/), one-time move scripts (scripts/migrate/), and prod Caddy/LiveKit config templates (infra/). Repoints the desktop publish/changelog URLs and prod ops config to .de. JWT_SECRET + VAPID copied identically so already-installed clients keep working; the new server also serves the legacy .cloud hostnames. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
9.5 KiB
Bash
208 lines
9.5 KiB
Bash
#!/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 <<EOF
|
|
|
|
----------------------------------------------------------------------------
|
|
ABOUT TO MIGRATE DATA: OLD ${OLD_SSH} -> 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 <<EOF
|
|
|
|
============================================================================
|
|
DATA MIGRATION DONE.
|
|
============================================================================
|
|
Verify on NEW:
|
|
* users can log in (existing baked anon JWT must be accepted),
|
|
* a known storage object downloads via
|
|
https://supabase.netralax.de/storage/v1/object/...,
|
|
* realtime + push still work.
|
|
If storage objects 403 due to ownership, on NEW run:
|
|
cd ${SUPABASE_DIR} && docker compose restart storage imgproxy
|
|
Do NOT decommission the OLD VPS until the .cloud DNS A-records point at NEW
|
|
and old clients have had a chance to auto-update.
|
|
============================================================================
|
|
EOF
|