Files
ChatApp/scripts/migrate/02-migrate-data.sh
T
byGalax 5bc30c950c feat(infra): migrate self-hosted backend to netralax.de
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>
2026-06-02 19:39:04 +02:00

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