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>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Bootstrap a fresh netralax.de VPS so it can host the Supabase + LiveKit stack.
|
||||
#
|
||||
# COPY THIS SCRIPT TO THE NEW SERVER AND RUN IT THERE as root (or via sudo):
|
||||
# scp scripts/migrate/01-bootstrap-new-server.sh debian@141.95.34.204:/tmp/
|
||||
# ssh debian@141.95.34.204 'sudo bash /tmp/01-bootstrap-new-server.sh'
|
||||
#
|
||||
# It is idempotent: re-running it only fills in what is missing. It installs
|
||||
# Docker CE + the compose plugin, opens the firewall, clones supabase/supabase,
|
||||
# prepares /opt/livekit, installs Caddy, creates the update host + deploy user,
|
||||
# and writes placeholder config. It NEVER fabricates secret values — those you
|
||||
# copy from the old server (see the NEXT STEPS block it prints at the end).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- must run as root ------------------------------------------------------
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "this script must run as root (use: sudo bash $0)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUPABASE_DIR="/opt/supabase"
|
||||
LIVEKIT_DIR="/opt/livekit"
|
||||
UPDATES_DIR="/var/www/updates/windows"
|
||||
DEPLOY_USER="chatapp-deploy"
|
||||
|
||||
log() { echo "==> $*"; }
|
||||
|
||||
# --- base packages ---------------------------------------------------------
|
||||
log "updating apt and installing base packages"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -y
|
||||
apt-get install -y \
|
||||
ca-certificates curl gnupg lsb-release git ufw rsync apt-transport-https
|
||||
|
||||
# --- Docker CE + compose plugin -------------------------------------------
|
||||
if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
log "docker + compose plugin already installed — skipping"
|
||||
else
|
||||
log "installing Docker CE + compose plugin (official repo)"
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
fi
|
||||
. /etc/os-release
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -y
|
||||
apt-get install -y \
|
||||
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
systemctl enable --now docker
|
||||
fi
|
||||
# Let the login user run docker/compose without sudo (effective on next login).
|
||||
usermod -aG docker "${SUDO_USER:-debian}" || true
|
||||
|
||||
# --- firewall (ufw) --------------------------------------------------------
|
||||
# Media + TURN ports bypass Caddy entirely and MUST be open or calls have no A/V.
|
||||
log "configuring ufw"
|
||||
ufw allow 22/tcp comment 'ssh'
|
||||
ufw allow 80/tcp comment 'http (caddy / lets encrypt)'
|
||||
ufw allow 443/tcp comment 'https (caddy)'
|
||||
ufw allow 7880/tcp comment 'livekit signaling ws (behind caddy)'
|
||||
ufw allow 7881/tcp comment 'livekit rtc tcp fallback'
|
||||
ufw allow 50000:50100/udp comment 'livekit rtc udp'
|
||||
ufw allow 3478/tcp comment 'coturn'
|
||||
ufw allow 3478/udp comment 'coturn'
|
||||
ufw allow 5349/tcp comment 'coturn turns (tls)'
|
||||
ufw allow 50200:50300/udp comment 'coturn turn relay'
|
||||
# Enable non-interactively (idempotent — re-enabling is a no-op).
|
||||
ufw --force enable
|
||||
ufw status verbose || true
|
||||
|
||||
# --- Supabase (clone upstream, prepare .env) -------------------------------
|
||||
if [[ -d "${SUPABASE_DIR}/.git" || -f "${SUPABASE_DIR}/docker-compose.yml" ]]; then
|
||||
log "${SUPABASE_DIR} already populated — skipping clone"
|
||||
else
|
||||
log "cloning supabase/supabase into a temp dir and laying out ${SUPABASE_DIR}"
|
||||
tmp="$(mktemp -d)"
|
||||
git clone --depth 1 https://github.com/supabase/supabase "${tmp}/supabase"
|
||||
mkdir -p "${SUPABASE_DIR}"
|
||||
# The runnable self-hosted stack lives in supabase/docker.
|
||||
cp -r "${tmp}/supabase/docker/." "${SUPABASE_DIR}/"
|
||||
rm -rf "${tmp}"
|
||||
fi
|
||||
|
||||
# Prepare .env from the example WITHOUT inventing secrets.
|
||||
#
|
||||
# IMPORTANT: Supabase's upstream .env.example does NOT ship blank secrets — it
|
||||
# ships well-known PUBLIC default values (JWT_SECRET=your-super-secret..., the
|
||||
# matching default ANON_KEY/SERVICE_ROLE_KEY, POSTGRES_PASSWORD, etc.). Booting
|
||||
# with those is both a security hole AND wrong: the baked anon key in installed
|
||||
# clients is signed with the OLD server's JWT_SECRET, so a default secret makes
|
||||
# the gateway reject every token and drop all sessions — silently. So we
|
||||
# OVERWRITE the security-critical keys with a loud sentinel that fails fast if
|
||||
# someone forgets to fill them from the old server.
|
||||
SENTINEL="__COPY_FROM_OLD_SERVER__"
|
||||
CRIT_KEYS=(POSTGRES_PASSWORD JWT_SECRET ANON_KEY SERVICE_ROLE_KEY \
|
||||
SECRET_KEY_BASE VAULT_ENC_KEY DASHBOARD_PASSWORD)
|
||||
if [[ -f "${SUPABASE_DIR}/.env" ]]; then
|
||||
log "${SUPABASE_DIR}/.env already exists — leaving it untouched"
|
||||
elif [[ -f "${SUPABASE_DIR}/.env.example" ]]; then
|
||||
cp "${SUPABASE_DIR}/.env.example" "${SUPABASE_DIR}/.env"
|
||||
for k in "${CRIT_KEYS[@]}"; do
|
||||
sed -i "s|^${k}=.*|${k}=${SENTINEL}|" "${SUPABASE_DIR}/.env" || true
|
||||
done
|
||||
log "wrote ${SUPABASE_DIR}/.env — critical secrets set to ${SENTINEL}."
|
||||
log "These are NOT blank by default upstream; you MUST copy the real values"
|
||||
log "1:1 from the OLD server's /opt/supabase/.env (esp. JWT_SECRET + VAPID)."
|
||||
else
|
||||
log "WARNING: no .env.example found in ${SUPABASE_DIR}; create .env by hand"
|
||||
fi
|
||||
|
||||
# --- LiveKit dir -----------------------------------------------------------
|
||||
log "preparing ${LIVEKIT_DIR}"
|
||||
mkdir -p "${LIVEKIT_DIR}"
|
||||
if [[ ! -f "${LIVEKIT_DIR}/livekit.yaml" ]]; then
|
||||
cat > "${LIVEKIT_DIR}/livekit.yaml" <<'YAML'
|
||||
# PLACEHOLDER — replace with infra/livekit/livekit.prod.yaml.example contents.
|
||||
# Prod config MUST set rtc.use_external_ip: true and must NOT hardcode
|
||||
# node_ip: 127.0.0.1 (that is dev-only). Fill the keys: block with the SAME
|
||||
# API key/secret as LIVEKIT_API_KEY / LIVEKIT_API_SECRET in /opt/supabase/.env.
|
||||
YAML
|
||||
log "wrote placeholder ${LIVEKIT_DIR}/livekit.yaml"
|
||||
fi
|
||||
if [[ ! -f "${LIVEKIT_DIR}/coturn.conf" ]]; then
|
||||
cat > "${LIVEKIT_DIR}/coturn.conf" <<'CONF'
|
||||
# PLACEHOLDER — replace with infra/livekit/coturn.prod.conf.example contents.
|
||||
# Set external-ip to this VPS's public IP, point cert/pkey at the TLS cert for
|
||||
# turn.netralax.de, and set a real lt-cred-mech user/password.
|
||||
CONF
|
||||
log "wrote placeholder ${LIVEKIT_DIR}/coturn.conf"
|
||||
fi
|
||||
if [[ ! -f "${LIVEKIT_DIR}/docker-compose.yml" ]]; then
|
||||
cat > "${LIVEKIT_DIR}/docker-compose.yml" <<'YAML'
|
||||
# PLACEHOLDER — replace with infra/livekit/docker-compose.prod.yml.example.
|
||||
# The dev infra/livekit/docker-compose.yml is NOT suitable for prod (coturn runs
|
||||
# with --no-tls, no 5349, no cert). The prod compose uses network_mode: host,
|
||||
# mounts ./livekit.yaml + ./coturn.conf, runs coturn with -c turnserver.conf,
|
||||
# and mounts /etc/letsencrypt for the turn.netralax.de TURNS cert.
|
||||
YAML
|
||||
log "wrote placeholder ${LIVEKIT_DIR}/docker-compose.yml"
|
||||
fi
|
||||
|
||||
# --- Caddy (official apt repo) --------------------------------------------
|
||||
if command -v caddy >/dev/null 2>&1; then
|
||||
log "caddy already installed — skipping"
|
||||
else
|
||||
log "installing Caddy (official repo)"
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
> /etc/apt/sources.list.d/caddy-stable.list
|
||||
apt-get update -y
|
||||
apt-get install -y caddy
|
||||
systemctl enable caddy
|
||||
fi
|
||||
|
||||
# Write a placeholder Caddyfile if none exists (do not clobber a real one).
|
||||
if [[ ! -s /etc/caddy/Caddyfile ]] || grep -q 'PLACEHOLDER' /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
cat > /etc/caddy/Caddyfile <<'CADDY'
|
||||
# PLACEHOLDER Caddyfile — replace with infra/caddy/Caddyfile from the repo.
|
||||
# Serve the .de vhosts now; add the legacy .cloud vhosts only at cutover (after
|
||||
# the .cloud DNS is repointed) so they keep already-installed clients working:
|
||||
# supabase.netralax.de { reverse_proxy localhost:8000 }
|
||||
# livekit.netralax.de { reverse_proxy localhost:7880 }
|
||||
# update.netralax.de { root * /var/www/updates # NOT .../windows — see Caddyfile
|
||||
# file_server }
|
||||
CADDY
|
||||
log "wrote placeholder /etc/caddy/Caddyfile"
|
||||
fi
|
||||
|
||||
# --- update host + deploy user --------------------------------------------
|
||||
log "preparing update host at ${UPDATES_DIR}"
|
||||
mkdir -p "${UPDATES_DIR}"
|
||||
|
||||
if id "${DEPLOY_USER}" >/dev/null 2>&1; then
|
||||
log "user ${DEPLOY_USER} already exists — skipping"
|
||||
else
|
||||
log "creating deploy user ${DEPLOY_USER}"
|
||||
useradd --create-home --shell /bin/bash "${DEPLOY_USER}"
|
||||
mkdir -p "/home/${DEPLOY_USER}/.ssh"
|
||||
chmod 700 "/home/${DEPLOY_USER}/.ssh"
|
||||
touch "/home/${DEPLOY_USER}/.ssh/authorized_keys"
|
||||
chmod 600 "/home/${DEPLOY_USER}/.ssh/authorized_keys"
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
|
||||
fi
|
||||
# Let the deploy user write release artifacts.
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${UPDATES_DIR}"
|
||||
|
||||
# --- next steps ------------------------------------------------------------
|
||||
cat <<EOF
|
||||
|
||||
============================================================================
|
||||
BOOTSTRAP DONE — manual NEXT STEPS (this script invents NO secrets):
|
||||
============================================================================
|
||||
|
||||
1. Fill ${SUPABASE_DIR}/.env. Copy these 1:1 from the OLD server's
|
||||
/opt/supabase/.env so baked-in client tokens + push keep working:
|
||||
POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY,
|
||||
SECRET_KEY_BASE, VAULT_ENC_KEY, PG_META_CRYPTO_KEY,
|
||||
SMTP_*, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT,
|
||||
PUSH_FANOUT_SHARED_SECRET, LIVEKIT_API_KEY, LIVEKIT_API_SECRET.
|
||||
Set these to the NEW host:
|
||||
SITE_URL / API_EXTERNAL_URL / SUPABASE_PUBLIC_URL = https://supabase.netralax.de
|
||||
SUPABASE_URL = https://supabase.netralax.de
|
||||
LIVEKIT_URL = wss://livekit.netralax.de
|
||||
ADDITIONAL_REDIRECT_URLS must include (comma-separated, no spaces):
|
||||
chatapp://auth/callback,netralax://auth/callback,
|
||||
https://supabase.netralax.de,https://supabase.netralax.cloud
|
||||
|
||||
2. Drop the real LiveKit + coturn config in ${LIVEKIT_DIR}:
|
||||
docker-compose.yml <- infra/livekit/docker-compose.prod.yml.example
|
||||
livekit.yaml <- infra/livekit/livekit.prod.yaml.example
|
||||
coturn.conf <- infra/livekit/coturn.prod.conf.example
|
||||
Set rtc.use_external_ip: true, NO node_ip: 127.0.0.1, coturn external-ip
|
||||
= this VPS's public IP, and a TLS cert for turn.netralax.de.
|
||||
The LiveKit keys: block MUST match LIVEKIT_API_KEY/SECRET in .env.
|
||||
(The on-server filenames are livekit.yaml / coturn.conf — same names
|
||||
rotate-livekit-keys.sh expects.)
|
||||
|
||||
3. Place the real Caddyfile:
|
||||
cp infra/caddy/Caddyfile /etc/caddy/Caddyfile && systemctl reload caddy
|
||||
(serves both .de and .cloud vhosts).
|
||||
|
||||
4. Bring the Supabase DB up ONCE so init scripts create the roles, then
|
||||
restore data from the laptop:
|
||||
cd ${SUPABASE_DIR} && docker compose up -d db && sleep 20
|
||||
# then on the laptop: ./scripts/migrate/02-migrate-data.sh
|
||||
|
||||
5. Repoint DNS A-records to THIS VPS's IP for BOTH domains:
|
||||
supabase.netralax.de / .cloud, livekit.netralax.de / .cloud,
|
||||
turn.netralax.de, update.netralax.de / .cloud.
|
||||
|
||||
6. Add the chatapp-deploy public key to
|
||||
/home/${DEPLOY_USER}/.ssh/authorized_keys
|
||||
and mirror electron-updater artifacts (latest.yml, *.exe, changelog.json)
|
||||
under ${UPDATES_DIR} so BOTH update.netralax.de and .cloud serve them.
|
||||
============================================================================
|
||||
EOF
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
// 03-copy-secrets.mjs — merge the OLD server's /opt/supabase/.env into the NEW
|
||||
// server's .env, keeping every secret byte-identical, then force the public-URL
|
||||
// / redirect vars to the netralax.de host. Run from the dev laptop.
|
||||
//
|
||||
// WHY Node (not bash/sed): secret values (JWT tokens, base64 keys, SMTP
|
||||
// passwords) contain characters that wreck shell/sed escaping. Here the values
|
||||
// only ever travel over SSH stdin/stdout and are handled as plain JS strings —
|
||||
// never interpolated into a shell command. Nothing is written to the laptop disk.
|
||||
//
|
||||
// MERGE SEMANTICS (loss-free):
|
||||
// - base = the NEW .env (fresh upstream structure + comments + new-only keys)
|
||||
// - for every key that exists on BOTH sides -> take the OLD value
|
||||
// - for every key that exists ONLY on OLD -> append it (this is how the
|
||||
// custom edge secrets VAPID_*/PUSH_FANOUT_SHARED_SECRET/LIVEKIT_API_*
|
||||
// survive — they are not in the fresh upstream .env)
|
||||
// - keys ONLY on NEW -> keep their fresh default
|
||||
// - finally, the OVERRIDES below are upserted (public host = .de)
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/migrate/03-copy-secrets.mjs --check # show plan, change nothing
|
||||
// node scripts/migrate/03-copy-secrets.mjs # back up + apply on NEW
|
||||
//
|
||||
// Pre-req: SSH works to BOTH hosts (prox@OLD, debian@NEW) and NEW's .env exists
|
||||
// (bootstrap step done). OLD/NEW are read from scripts/migrate/config.sh.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// --- read hosts from config.sh (single source of truth) --------------------
|
||||
const cfg = readFileSync(join(here, 'config.sh'), 'utf8');
|
||||
const cfgVal = (name) => {
|
||||
const m = cfg.match(new RegExp(`^export ${name}="([^"]*)"`, 'm'));
|
||||
if (!m) throw new Error(`could not find ${name} in config.sh`);
|
||||
return m[1];
|
||||
};
|
||||
const OLD_USER = cfgVal('OLD_USER');
|
||||
const OLD_HOST = cfgVal('OLD_HOST');
|
||||
const NEW_USER = cfgVal('NEW_USER');
|
||||
const NEW_HOST = cfgVal('NEW_HOST');
|
||||
const SUPABASE_DIR = cfgVal('SUPABASE_DIR');
|
||||
const ENV_PATH = `${SUPABASE_DIR}/.env`;
|
||||
const OLD_SSH = `${OLD_USER}@${OLD_HOST}`;
|
||||
const NEW_SSH = `${NEW_USER}@${NEW_HOST}`;
|
||||
const SSH_OPTS = ['-o', 'StrictHostKeyChecking=accept-new'];
|
||||
|
||||
if (NEW_HOST === '__NETRALAX_DE_SERVER_IP__' || !NEW_HOST) {
|
||||
console.error('NEW_HOST is still the placeholder — edit scripts/migrate/config.sh first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- public-host overrides (forced to .de AFTER the merge) -----------------
|
||||
// NOTE: SUPABASE_URL is deliberately NOT overridden — for the edge-runtime it
|
||||
// is the INTERNAL gateway URL and is handled by the compose env in §8, not here.
|
||||
const NEW_SITE = 'https://supabase.netralax.de';
|
||||
const NEW_LIVEKIT = 'wss://livekit.netralax.de';
|
||||
const OVERRIDES = {
|
||||
SITE_URL: NEW_SITE,
|
||||
API_EXTERNAL_URL: NEW_SITE,
|
||||
SUPABASE_PUBLIC_URL: NEW_SITE,
|
||||
LIVEKIT_URL: NEW_LIVEKIT,
|
||||
ADDITIONAL_REDIRECT_URLS:
|
||||
'chatapp://auth/callback,netralax://auth/callback,' +
|
||||
'https://supabase.netralax.de,https://supabase.netralax.cloud',
|
||||
};
|
||||
|
||||
// Continuity-critical keys: their value MUST end up identical to OLD.
|
||||
const CRITICAL = [
|
||||
'JWT_SECRET', 'ANON_KEY', 'SERVICE_ROLE_KEY', 'POSTGRES_PASSWORD',
|
||||
'VAPID_PUBLIC_KEY', 'VAPID_PRIVATE_KEY', 'PUSH_FANOUT_SHARED_SECRET',
|
||||
'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET',
|
||||
];
|
||||
|
||||
const check = process.argv.includes('--check');
|
||||
|
||||
// --- ssh helpers (values flow via stdio, never via argv) -------------------
|
||||
function ssh(target, remoteCmd, input) {
|
||||
return execFileSync('ssh', [...SSH_OPTS, target, remoteCmd], {
|
||||
encoding: 'utf8',
|
||||
input: input ?? undefined,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
const readOldEnv = () => ssh(OLD_SSH, `sudo cat ${ENV_PATH} 2>/dev/null || cat ${ENV_PATH}`);
|
||||
const readNewEnv = () => ssh(NEW_SSH, `sudo cat ${ENV_PATH}`);
|
||||
|
||||
// --- env parsing (split on FIRST '='; keep comments/blank lines as raw) -----
|
||||
const KEY_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
|
||||
function parse(text) {
|
||||
const map = new Map();
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(KEY_RE);
|
||||
if (m) map.set(m[1], m[2]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
const sha = (s) => createHash('sha256').update(s ?? '').digest('hex').slice(0, 12);
|
||||
|
||||
// --- main ------------------------------------------------------------------
|
||||
console.log(`OLD: ${OLD_SSH} NEW: ${NEW_SSH} file: ${ENV_PATH}\n`);
|
||||
|
||||
let oldText, newText;
|
||||
try { oldText = readOldEnv(); } catch (e) {
|
||||
console.error(`Failed to read OLD .env via ssh ${OLD_SSH}.\n${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
try { newText = readNewEnv(); } catch (e) {
|
||||
console.error(`Failed to read NEW .env via ssh ${NEW_SSH} (bootstrap done?).\n${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const oldMap = parse(oldText);
|
||||
const newMap = parse(newText);
|
||||
const onlyOld = [...oldMap.keys()].filter((k) => !newMap.has(k)).sort();
|
||||
const onlyNew = [...newMap.keys()].filter((k) => !oldMap.has(k)).sort();
|
||||
const shared = [...oldMap.keys()].filter((k) => newMap.has(k)).sort();
|
||||
|
||||
console.log(`shared keys (value taken from OLD): ${shared.length}`);
|
||||
console.log(`OLD-only keys (appended — incl. custom edge secrets): ${onlyOld.length}`);
|
||||
onlyOld.forEach((k) => console.log(` + ${k}`));
|
||||
console.log(`NEW-only keys (kept at fresh default): ${onlyNew.length}`);
|
||||
onlyNew.forEach((k) => console.log(` . ${k}`));
|
||||
console.log(`\noverrides forced to the .de host:`);
|
||||
for (const [k, v] of Object.entries(OVERRIDES)) console.log(` ${k}=${v}`);
|
||||
|
||||
// sanity: warn if a continuity-critical key is missing on OLD
|
||||
const missingCrit = CRITICAL.filter((k) => !oldMap.has(k));
|
||||
if (missingCrit.length) {
|
||||
console.log(`\n⚠ NOTE: these critical keys are absent on OLD (verify they aren't named differently): ${missingCrit.join(', ')}`);
|
||||
}
|
||||
|
||||
// --- build merged content (preserve NEW order/comments) --------------------
|
||||
const used = new Set();
|
||||
let lines = newText.split('\n').map((line) => {
|
||||
const m = line.match(KEY_RE);
|
||||
if (m && oldMap.has(m[1])) { used.add(m[1]); return `${m[1]}=${oldMap.get(m[1])}`; }
|
||||
return line;
|
||||
});
|
||||
// append OLD-only keys
|
||||
if (onlyOld.length) {
|
||||
if (lines.length && lines[lines.length - 1] !== '') lines.push('');
|
||||
lines.push('# --- merged from OLD server (keys not present in fresh upstream .env) ---');
|
||||
for (const k of onlyOld) { lines.push(`${k}=${oldMap.get(k)}`); used.add(k); }
|
||||
}
|
||||
// upsert overrides
|
||||
for (const [k, v] of Object.entries(OVERRIDES)) {
|
||||
let hit = false;
|
||||
lines = lines.map((line) => {
|
||||
const m = line.match(KEY_RE);
|
||||
if (m && m[1] === k) { hit = true; return `${k}=${v}`; }
|
||||
return line;
|
||||
});
|
||||
if (!hit) lines.push(`${k}=${v}`);
|
||||
}
|
||||
const merged = lines.join('\n');
|
||||
|
||||
if (check) {
|
||||
console.log('\n--check: nothing written. Re-run without --check to apply.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// --- apply on NEW: backup, then write via `sudo tee` (content via stdin) ----
|
||||
console.log('\nbacking up NEW .env and writing merged result...');
|
||||
ssh(NEW_SSH, `sudo cp ${ENV_PATH} ${ENV_PATH}.bak.$(date +%s)`);
|
||||
ssh(NEW_SSH, `sudo tee ${ENV_PATH} > /dev/null`, merged.endsWith('\n') ? merged : merged + '\n');
|
||||
|
||||
// --- verify continuity: critical values identical OLD vs NEW ---------------
|
||||
const newAfter = parse(readNewEnv());
|
||||
console.log('\nverifying continuity (OLD value == NEW value):');
|
||||
let fail = 0;
|
||||
for (const k of CRITICAL) {
|
||||
if (!oldMap.has(k)) { console.log(` skip ${k} (not on OLD)`); continue; }
|
||||
const ok = oldMap.get(k) === newAfter.get(k);
|
||||
console.log(` ${ok ? 'OK ' : 'FAIL'} ${k} (sha ${sha(oldMap.get(k))} vs ${sha(newAfter.get(k))})`);
|
||||
if (!ok) fail++;
|
||||
}
|
||||
console.log('\noverrides now on NEW:');
|
||||
for (const k of Object.keys(OVERRIDES)) console.log(` ${k}=${newAfter.get(k)}`);
|
||||
|
||||
if (fail) {
|
||||
console.error(`\n✗ ${fail} critical key(s) did not match — DO NOT proceed. Restore from the .bak.* backup and investigate.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✓ secrets merged; JWT_SECRET + VAPID + LiveKit keys are identical to OLD. Continue with runbook §6 (LiveKit/coturn) and §5 (data).');
|
||||
@@ -0,0 +1,132 @@
|
||||
# Server-Umzug: netralax.cloud -> netralax.de
|
||||
|
||||
Einmalige Migration des selbst gehosteten Chat-Backends vom **alten VPS**
|
||||
(`46.225.156.249`, `*.netralax.cloud`) auf einen **neuen, leeren VPS**
|
||||
(`*.netralax.de`). Der neue Server bedient anschliessend **beide** Domains,
|
||||
damit bereits installierte Desktop-/Mobile-Clients (die alte Hostnamen und den
|
||||
alten anon-JWT fest eingebaut haben) weiterlaufen, bis sie sich selbst
|
||||
aktualisieren.
|
||||
|
||||
> Diese Skripte sind bewusst getrennt von `scripts/prod/`. `scripts/prod/config.sh`
|
||||
> kennt nur den jeweils **aktiven** Server; der Umzug braucht **beide** Hosts und
|
||||
> hat deshalb seine eigene `scripts/migrate/config.sh`.
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Wo ausführen | Zweck |
|
||||
|-------|--------------|-------|
|
||||
| `config.sh` | – | Gemeinsame Konfiguration (alter + neuer Host, SSH-Helfer). Wird von den anderen Skripten eingebunden. |
|
||||
| `01-bootstrap-new-server.sh` | **auf dem neuen VPS** (als root / sudo) | Richtet den leeren Server ein: Docker, ufw, Supabase-Clone, LiveKit-Verzeichnis, Caddy, Update-Host, Deploy-User. |
|
||||
| `02-migrate-data.sh` | **auf dem Entwickler-Laptop** | Überträgt Postgres-Daten (pg_dumpall) und die Storage-Objekte (rsync) von alt nach neu. |
|
||||
|
||||
## Voraussetzungen / Einrichtung (einmalig)
|
||||
|
||||
1. **Neue Server-IP — bereits eingetragen.** `scripts/migrate/config.sh` hat
|
||||
`NEW_HOST="141.95.34.204"` und `NEW_USER="debian"`. (Der `require_new_host`-
|
||||
Guard greift nur, falls der Platzhalter wieder drinsteht.)
|
||||
|
||||
2. **SSH-Zugriff.** Vom Laptop muss `ssh prox@46.225.156.249` (alt) **und**
|
||||
`ssh debian@141.95.34.204` (neu) ohne Passwort funktionieren:
|
||||
```
|
||||
ssh-copy-id prox@46.225.156.249
|
||||
ssh-copy-id debian@141.95.34.204
|
||||
```
|
||||
Für den direkten Storage-Transfer (Server-zu-Server) muss zusätzlich der
|
||||
**alte** Server per SSH auf den **neuen** zugreifen können. Klappt das nicht,
|
||||
fällt `02-migrate-data.sh` automatisch auf den Umweg über den Laptop zurück.
|
||||
|
||||
3. **Skripte ausführbar machen:**
|
||||
```
|
||||
chmod +x scripts/migrate/*.sh
|
||||
```
|
||||
|
||||
## Ablauf (Reihenfolge unbedingt einhalten)
|
||||
|
||||
1. **Bootstrap auf dem neuen Server.** Skript hochladen und als root ausführen:
|
||||
```
|
||||
scp scripts/migrate/01-bootstrap-new-server.sh debian@141.95.34.204:/tmp/
|
||||
ssh debian@141.95.34.204 'sudo bash /tmp/01-bootstrap-new-server.sh'
|
||||
```
|
||||
Das Skript ist idempotent (mehrfaches Ausführen schadet nicht) und gibt am
|
||||
Ende einen **NEXT STEPS**-Block aus.
|
||||
|
||||
2. **Secrets eintragen.** `/opt/supabase/.env` auf dem neuen Server befüllen.
|
||||
Diese Werte **1:1 vom alten Server kopieren** (sonst brechen eingebaute
|
||||
Tokens, Sessions und Web-Push):
|
||||
`POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`,
|
||||
`SECRET_KEY_BASE`, `VAULT_ENC_KEY`, `PG_META_CRYPTO_KEY`, alle `SMTP_*`,
|
||||
`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`,
|
||||
`PUSH_FANOUT_SHARED_SECRET`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`.
|
||||
Auf die **neue** Domain zeigen:
|
||||
`SITE_URL`, `API_EXTERNAL_URL`, `SUPABASE_PUBLIC_URL`, `SUPABASE_URL`
|
||||
= `https://supabase.netralax.de`, `LIVEKIT_URL` = `wss://livekit.netralax.de`.
|
||||
`ADDITIONAL_REDIRECT_URLS` (komma-getrennt, **ohne Leerzeichen**) muss
|
||||
enthalten: `chatapp://auth/callback`, `netralax://auth/callback` sowie
|
||||
`https://supabase.netralax.de` und `https://supabase.netralax.cloud`.
|
||||
|
||||
3. **Server-Konfig platzieren.**
|
||||
- `infra/livekit/docker-compose.prod.yml.example` -> `/opt/livekit/docker-compose.yml`
|
||||
(Prod-Compose: host-networking, mountet `livekit.yaml` + `coturn.conf` +
|
||||
`/etc/letsencrypt`; das Dev-Compose taugt **nicht** für Prod).
|
||||
- `infra/livekit/livekit.prod.yaml.example` -> `/opt/livekit/livekit.yaml`
|
||||
(`rtc.use_external_ip: true`, **kein** `node_ip: 127.0.0.1`, `keys:`-Block
|
||||
identisch zu `LIVEKIT_API_KEY/SECRET` aus der `.env`).
|
||||
- `infra/livekit/coturn.prod.conf.example` -> `/opt/livekit/coturn.conf`
|
||||
(`external-ip` = öffentliche IP des neuen VPS, TLS-Cert für
|
||||
`turn.netralax.de`).
|
||||
- `infra/caddy/Caddyfile` -> `/etc/caddy/Caddyfile`, danach
|
||||
`systemctl reload caddy`. Caddy bedient **beide** Domains (.de und .cloud).
|
||||
|
||||
4. **Stacks starten – DB zuerst einmal hochfahren**, damit die Supabase-Init-
|
||||
Skripte die Rollen anlegen (vor dem Restore):
|
||||
```
|
||||
ssh debian@141.95.34.204 'cd /opt/supabase && docker compose up -d db && sleep 20'
|
||||
```
|
||||
|
||||
5. **Schreibzugriffe auf dem ALTEN System einfrieren** (Wartungsmodus). Sonst
|
||||
landen während des Umzugs neue Uploads/Zeilen nur auf einer Seite und
|
||||
DB + Storage werden inkonsistent.
|
||||
|
||||
6. **Daten migrieren** (vom Laptop). Erst der Trockenlauf, dann die Migration:
|
||||
```
|
||||
./scripts/migrate/02-migrate-data.sh --check # nur Pre-Flight, keine Änderung
|
||||
./scripts/migrate/02-migrate-data.sh # fragt nach Bestätigung
|
||||
```
|
||||
Das Skript dumpt den **gesamten** Cluster per `pg_dumpall` und spielt ihn auf
|
||||
dem neuen Server ein, danach rsync der Storage-Objekte. `push-migrations.sh`
|
||||
**nicht** erneut ausführen – die Migrationen sind bereits im Dump enthalten.
|
||||
|
||||
7. **DNS umstellen.** A-Records für **beide** Domains auf die neue IP zeigen
|
||||
lassen: `supabase.netralax.de` / `.cloud`, `livekit.netralax.de` / `.cloud`,
|
||||
`turn.netralax.de`, `update.netralax.de` / `.cloud`.
|
||||
|
||||
8. **Update-Artefakte spiegeln.** electron-updater-Dateien (`latest.yml`,
|
||||
`*.exe`, `changelog.json`) unter `/var/www/updates/windows` ablegen, sodass
|
||||
**sowohl** `update.netralax.de` **als auch** `update.netralax.cloud` sie
|
||||
ausliefern. Nur so können alte (.cloud-)Clients die Umstiegs-Version ziehen.
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- **`JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`** müssen byteweise identisch
|
||||
vom alten Server stammen, **bevor** der erste Client den neuen Server trifft –
|
||||
sonst werden alle eingebauten Tokens abgelehnt und alle Sessions fliegen raus.
|
||||
- **`POSTGRES_PASSWORD`** muss vor dem Restore identisch gesetzt sein, weil der
|
||||
Dump die Rollen-Passwort-Hashes mitbringt. Sonst können sich die internen
|
||||
Dienste (auth/rest/storage) nach dem Restore nicht mehr an Postgres anmelden.
|
||||
- **VAPID-Schlüsselpaar** identisch übernehmen, sonst sind alle bestehenden
|
||||
Web-Push-Abos ungültig.
|
||||
- **Medien-/TURN-Ports** müssen in ufw offen sein (7880/7881 tcp, 50000-50100
|
||||
udp, coturn 3478 tcp+udp, 5349 tcp, 50200-50300 udp) – sonst haben Anrufe kein
|
||||
Audio/Video. Diese Ports laufen **nicht** über Caddy.
|
||||
- **TURNS auf 5349** braucht ein eigenes TLS-Zertifikat für `turn.netralax.de`
|
||||
auf der Platte (Pfade in `coturn.conf`) – ein reines Caddy-Zertifikat reicht
|
||||
nicht.
|
||||
- **Alten VPS nicht abschalten**, bevor die `.cloud`-DNS-Einträge auf den neuen
|
||||
Server zeigen und alte Clients Zeit zum Auto-Update hatten.
|
||||
- Beim Restore werden harmlose `already exists`-Fehler für vorhandene Rollen
|
||||
(`supabase_admin`, `postgres` …) ausgegeben – das ist gewollt
|
||||
(`ON_ERROR_STOP=0`). `02-migrate-data.sh` scannt die Restore-Ausgabe
|
||||
**automatisch** auf echte `ERROR/FATAL/PANIC` und **bricht vor dem Storage-
|
||||
rsync ab**, wenn welche übrig bleiben (Override: `FORCE_RESTORE_OK=1`).
|
||||
Danach macht es eine Zeilen-Paritätsprüfung (alt vs. neu) über die tragenden
|
||||
Tabellen.
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared config for the one-time netralax.cloud -> netralax.de server move.
|
||||
#
|
||||
# This is SEPARATE from scripts/prod/config.sh on purpose: the migration knows
|
||||
# BOTH the old (.cloud) and the new (.de) host, whereas scripts/prod/config.sh
|
||||
# only ever points at the live server. Source this in each migrate script:
|
||||
# source "$(dirname "$0")/config.sh"
|
||||
#
|
||||
# Fill NEW_HOST once the netralax.de VPS exists. OLD_HOST is the .cloud VPS.
|
||||
|
||||
# Old, currently-live VPS (Supabase + LiveKit on *.netralax.cloud).
|
||||
export OLD_HOST="46.225.156.249"
|
||||
export OLD_USER="prox"
|
||||
|
||||
# New, empty VPS that will serve *.netralax.de (and keep serving *.netralax.cloud
|
||||
# for already-installed clients). Fill in the IP before running 02-migrate-data.sh.
|
||||
# NOTE: the login user on the new .de VPS is "debian" (the old .cloud VPS uses "prox").
|
||||
export NEW_HOST="141.95.34.204"
|
||||
export NEW_USER="debian"
|
||||
|
||||
# Paths on BOTH servers (same layout on old and new).
|
||||
export SUPABASE_DIR="/opt/supabase"
|
||||
export LIVEKIT_DIR="/opt/livekit"
|
||||
|
||||
# SSH helper opts: accept new host keys on first connect without prompting.
|
||||
# Override SSH_OPTS from the environment if you need a jumphost etc.
|
||||
export SSH_OPTS="${SSH_OPTS:--o StrictHostKeyChecking=accept-new}"
|
||||
|
||||
# Convenience SSH targets.
|
||||
export OLD_SSH="${OLD_USER}@${OLD_HOST}"
|
||||
export NEW_SSH="${NEW_USER}@${NEW_HOST}"
|
||||
|
||||
# Run a command on the OLD server.
|
||||
old_remote() {
|
||||
# shellcheck disable=SC2086
|
||||
ssh ${SSH_OPTS} "${OLD_SSH}" "$@"
|
||||
}
|
||||
|
||||
# Run a command on the NEW server.
|
||||
new_remote() {
|
||||
# shellcheck disable=SC2086
|
||||
ssh ${SSH_OPTS} "${NEW_SSH}" "$@"
|
||||
}
|
||||
|
||||
# Guard: refuse to run anything against the unfilled new-host placeholder.
|
||||
require_new_host() {
|
||||
if [[ "${NEW_HOST}" == "__NETRALAX_DE_SERVER_IP__" || -z "${NEW_HOST}" ]]; then
|
||||
echo "NEW_HOST is still the placeholder — edit scripts/migrate/config.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -8,8 +8,9 @@ All commands read shared config from `config.sh`.
|
||||
1. Copy your SSH key to the server so scripts don't prompt for a password:
|
||||
```
|
||||
ssh-keygen -t ed25519 # only if you don't already have one
|
||||
ssh-copy-id prox@46.225.156.249
|
||||
ssh prox@46.225.156.249 'echo ok'
|
||||
# PROD now points at the netralax.de VPS (user "debian"; see config.sh).
|
||||
ssh-copy-id debian@141.95.34.204
|
||||
ssh debian@141.95.34.204 'echo ok'
|
||||
```
|
||||
2. Make the scripts executable:
|
||||
```
|
||||
|
||||
+13
-5
@@ -5,16 +5,24 @@
|
||||
# Customize here when the server IP / domains change — all other scripts pick
|
||||
# the values up automatically.
|
||||
|
||||
export PROD_SERVER="46.225.156.249"
|
||||
export PROD_USER="prox"
|
||||
# End state after the netralax.de migration. The old .cloud VPS was
|
||||
# 46.225.156.249 — for the one-time move (data dump/restore, storage rsync)
|
||||
# use scripts/migrate/, which knows the old host explicitly. Fill in the new
|
||||
# server IP once the netralax.de VPS exists, then this becomes the live config
|
||||
# for all push-migrations / push-edge-function / create-invite / logs scripts.
|
||||
export PROD_SERVER="141.95.34.204"
|
||||
# Login user on the new .de VPS is "debian" (the old .cloud VPS used "prox").
|
||||
export PROD_USER="debian"
|
||||
|
||||
# Paths on the remote server.
|
||||
export PROD_SUPABASE_DIR="/opt/supabase"
|
||||
export PROD_LIVEKIT_DIR="/opt/livekit"
|
||||
|
||||
# Public domains (served via Caddy on the same VPS).
|
||||
export PROD_DOMAIN_SUPABASE="supabase.netralax.cloud"
|
||||
export PROD_DOMAIN_LIVEKIT="livekit.netralax.cloud"
|
||||
# Public domains (served via Caddy on the new VPS). Caddy also keeps serving
|
||||
# the legacy supabase.netralax.cloud / livekit.netralax.cloud vhosts (same
|
||||
# backends) so already-installed clients keep working until they auto-update.
|
||||
export PROD_DOMAIN_SUPABASE="supabase.netralax.de"
|
||||
export PROD_DOMAIN_LIVEKIT="livekit.netralax.de"
|
||||
|
||||
# SSH helper: forwards the standard `-o StrictHostKeyChecking=accept-new` so
|
||||
# first connections don't prompt. Override SSH_OPTS from the environment if
|
||||
|
||||
Reference in New Issue
Block a user