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:
byGalax
2026-06-02 19:39:04 +02:00
parent 588b843904
commit 5bc30c950c
15 changed files with 1807 additions and 11 deletions
+244
View File
@@ -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
+207
View File
@@ -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
+189
View File
@@ -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).');
+132
View File
@@ -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.
+51
View File
@@ -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
}
+3 -2
View File
@@ -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
View File
@@ -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