This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import hmac
import hashlib
import base64
import json
import time
import os
import sys
if len(sys.argv) != 2:
print("usage: python3 gen-jwt.py <jwt_secret>", file=sys.stderr)
sys.exit(1)
secret = sys.argv[1]
b = lambda d: base64.urlsafe_b64encode(d).rstrip(b"=").decode()
iat = int(time.time())
exp = iat + 315360000 # 10 years
for role in ["anon", "service_role"]:
h = b(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
p = b(json.dumps({"role": role, "iss": "supabase", "iat": iat, "exp": exp}, separators=(",", ":")).encode())
s = b(hmac.new(secret.encode(), (h + "." + p).encode(), hashlib.sha256).digest())
print(role + ": " + h + "." + p + "." + s)
+87
View File
@@ -0,0 +1,87 @@
# Production scripts
Helper scripts that talk to the Hetzner VPS hosting Supabase + LiveKit.
All commands read shared config from `config.sh`.
## Setup (once)
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'
```
2. Make the scripts executable:
```
chmod +x scripts/prod/*.sh
```
3. Edit `scripts/prod/config.sh` when the server IP or domains change.
## Scripts
### `push-migrations.sh [<filter>]`
Uploads `supabase/migrations/*.sql` to `/tmp/migrations` on the server and
runs each file through `docker compose exec db psql`. Pass an optional
filter (substring match) to only apply specific timestamps.
### `push-edge-function.sh <name>`
Uploads `supabase/functions/<name>/` to
`/opt/supabase/volumes/functions/<name>/` and restarts the edge-runtime
container.
### `gen-jwt.sh <jwt-secret>`
Wrapper around `scripts/gen-jwt.py`. Prints fresh 10-year `anon` and
`service_role` HS256 JWTs for the given secret. Paste the results into
`/opt/supabase/.env` (`ANON_KEY` / `SERVICE_ROLE_KEY`) and the desktop
`.env` (`SUPABASE_ANON_KEY`).
### `create-invite.sh <code> [<uses>] [<days>]`
`INSERT`s a signup-invite row into `public.invites`. Defaults: 50 uses,
365-day expiry.
### `logs.sh <stack> [<service>] [<tail-lines>]`
Follows logs from either the `supabase` or `livekit` compose stack.
### `restart.sh <stack> [<service>]`
Restarts one service, or (without a service) brings the whole stack down
and back up.
### `tunnel-db.sh [<local-port>]`
Opens `ssh -L <local>:localhost:5432`. Default local port is `5433`.
Leave it running while you use `psql` / `supabase db push` / etc.
### `tunnel-mailpit.sh [<local-port>]`
Opens a tunnel to the Mailpit web UI. Default local port is `8025`.
### `rotate-livekit-keys.sh`
Generates a fresh LiveKit API key + secret, rewrites the `keys:` block in
`/opt/livekit/livekit.yaml`, and restarts the SFU. After running, also
update the Supabase `.env` (`LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`) and
`restart.sh supabase functions` so the mint-livekit-token Edge Function
sees the new values.
## Typical workflows
**Ship a new migration**
```
# add supabase/migrations/20260501_my_change.sql locally
./scripts/prod/push-migrations.sh 20260501
```
**Ship a new Edge Function change**
```
# edit supabase/functions/mint-livekit-token/index.ts locally
./scripts/prod/push-edge-function.sh mint-livekit-token
```
**Debug a failing magic-link**
```
./scripts/prod/logs.sh supabase auth 200
./scripts/prod/tunnel-mailpit.sh # open http://localhost:8025
```
**Rotate LiveKit credentials**
```
./scripts/prod/rotate-livekit-keys.sh
# follow the printed hint to update supabase .env + restart functions
```
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Shared config for all prod scripts. Source this in each script:
# source "$(dirname "$0")/config.sh"
#
# 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"
# 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"
# SSH helper: forwards the standard `-o StrictHostKeyChecking=accept-new` so
# first connections don't prompt. Override SSH_OPTS from the environment if
# you need a jumphost etc.
export SSH_OPTS="${SSH_OPTS:--o StrictHostKeyChecking=accept-new}"
export SSH_HOST="${PROD_USER}@${PROD_SERVER}"
remote() {
# shellcheck disable=SC2086
ssh ${SSH_OPTS} "${SSH_HOST}" "$@"
}
remote_tty() {
# shellcheck disable=SC2086
ssh ${SSH_OPTS} -t "${SSH_HOST}" "$@"
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
#
# Insert a new signup-invite code into prod Supabase. Runs `INSERT INTO
# public.invites ...` via `docker exec psql`. Code is validated by the
# `handle_new_user` trigger when a new user registers.
#
# Usage:
# ./scripts/prod/create-invite.sh <code> [<uses_limit>] [<expires_days>]
# ./scripts/prod/create-invite.sh FRIENDS 20 365
#
# Defaults: uses_limit=50, expires_days=365
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
code="${1:-}"
uses_limit="${2:-50}"
expires_days="${3:-365}"
if [[ -z "${code}" ]]; then
echo "usage: $0 <code> [<uses_limit>=50] [<expires_days>=365]" >&2
exit 1
fi
if [[ ${#code} -lt 4 || ${#code} -gt 64 ]]; then
echo "code length must be between 4 and 64 chars (got ${#code})" >&2
exit 1
fi
sql="INSERT INTO public.invites (code, uses_limit, expires_at, disabled) \
VALUES ('${code}', ${uses_limit}, now() + interval '${expires_days} days', false) \
RETURNING code, uses_limit, expires_at;"
echo "creating invite '${code}' (uses=${uses_limit}, expires=${expires_days}d)"
remote "cd ${PROD_SUPABASE_DIR} && docker compose exec -T db psql -U postgres -d postgres -c \"${sql}\""
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
#
# Thin wrapper around scripts/gen-jwt.py that generates HS256 anon +
# service_role JWTs for a given JWT_SECRET. The keys are 10-year, iss=supabase.
#
# Usage:
# ./scripts/prod/gen-jwt.sh <jwt-secret>
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${here}/../.." && pwd)"
secret="${1:-}"
if [[ -z "${secret}" ]]; then
echo "usage: $0 <jwt-secret>" >&2
exit 1
fi
python3 "${repo_root}/scripts/gen-jwt.py" "${secret}"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
#
# Tail logs from a service in either the Supabase or LiveKit compose stack.
#
# Usage:
# ./scripts/prod/logs.sh <stack> [<service>] [<tail>]
# stack = supabase | livekit
# service = (optional) single service, default = all
# tail = (optional) line count, default = 100
#
# Examples:
# ./scripts/prod/logs.sh supabase # all services
# ./scripts/prod/logs.sh supabase auth # just auth
# ./scripts/prod/logs.sh supabase auth 300 # auth, last 300 lines
# ./scripts/prod/logs.sh livekit livekit 200
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
stack="${1:-}"
service="${2:-}"
tail_n="${3:-100}"
case "${stack}" in
supabase) dir="${PROD_SUPABASE_DIR}" ;;
livekit) dir="${PROD_LIVEKIT_DIR}" ;;
*)
echo "usage: $0 <supabase|livekit> [service] [tail-lines]" >&2
exit 1
;;
esac
cmd="cd ${dir} && docker compose logs --tail=${tail_n} -f"
if [[ -n "${service}" ]]; then
cmd+=" ${service}"
fi
remote_tty "${cmd}"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
#
# Deploy a Supabase Edge Function to the prod self-hosted stack.
# Uploads `supabase/functions/<name>/` to
# `${PROD_SUPABASE_DIR}/volumes/functions/<name>/` and restarts the
# edge-runtime container so it picks up the new code.
#
# Usage:
# ./scripts/prod/push-edge-function.sh <function-name>
# ./scripts/prod/push-edge-function.sh mint-livekit-token
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
name="${1:-}"
if [[ -z "${name}" ]]; then
echo "usage: $0 <function-name>" >&2
exit 1
fi
repo_root="$(cd "${here}/../.." && pwd)"
local_dir="${repo_root}/supabase/functions/${name}"
if [[ ! -d "${local_dir}" ]]; then
echo "function dir not found: ${local_dir}" >&2
exit 1
fi
remote_dir="${PROD_SUPABASE_DIR}/volumes/functions/${name}"
echo "syncing ${local_dir}${SSH_HOST}:${remote_dir}"
remote "mkdir -p ${remote_dir}"
# shellcheck disable=SC2086
scp ${SSH_OPTS} -r "${local_dir}/." "${SSH_HOST}:${remote_dir}/"
echo
echo "restarting edge-runtime"
remote "cd ${PROD_SUPABASE_DIR} && docker compose up -d functions"
remote "cd ${PROD_SUPABASE_DIR} && docker compose logs functions --tail=10"
echo
echo "done — function '${name}' deployed."
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# Ship local `supabase/migrations/*.sql` to the prod DB and apply them in
# alphabetical order (= timestamp order given Supabase's filename convention).
#
# Usage:
# ./scripts/prod/push-migrations.sh # apply all migrations
# ./scripts/prod/push-migrations.sh 20260501_foo # apply only matching files
#
# Migrations should be idempotent (`IF NOT EXISTS` / `OR REPLACE`) — the
# script stops on the first failing statement so you notice drift immediately.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
repo_root="$(cd "${here}/../.." && pwd)"
migrations_dir="${repo_root}/supabase/migrations"
if [[ ! -d "${migrations_dir}" ]]; then
echo "migrations dir not found: ${migrations_dir}" >&2
exit 1
fi
filter="${1:-}"
echo "syncing ${migrations_dir}${SSH_HOST}:/tmp/migrations"
remote "rm -rf /tmp/migrations && mkdir -p /tmp/migrations"
# shellcheck disable=SC2086
scp ${SSH_OPTS} -r "${migrations_dir}"/* "${SSH_HOST}:/tmp/migrations/"
echo
echo "applying migrations via docker exec db psql"
remote "cd ${PROD_SUPABASE_DIR} && for f in /tmp/migrations/*.sql; do \
case \"\$(basename \"\$f\")\" in \
*${filter}*) echo \"=== applying \$f ===\"; docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=1 < \"\$f\" ;; \
*) echo \"=== skipping \$f (filter '${filter}') ===\" ;; \
esac; \
done"
echo
echo "done."
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# Restart services in the Supabase or LiveKit compose stack.
#
# Usage:
# ./scripts/prod/restart.sh <stack> [<service>]
# stack = supabase | livekit
# service = (optional) restart just this one, default = whole stack
#
# Examples:
# ./scripts/prod/restart.sh supabase # down + up -d
# ./scripts/prod/restart.sh supabase auth # only auth
# ./scripts/prod/restart.sh livekit # both caddy + livekit
# ./scripts/prod/restart.sh livekit livekit # only livekit container
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
stack="${1:-}"
service="${2:-}"
case "${stack}" in
supabase) dir="${PROD_SUPABASE_DIR}" ;;
livekit) dir="${PROD_LIVEKIT_DIR}" ;;
*)
echo "usage: $0 <supabase|livekit> [service]" >&2
exit 1
;;
esac
if [[ -n "${service}" ]]; then
echo "restarting ${stack}/${service}"
remote "cd ${dir} && docker compose restart ${service}"
else
echo "restarting full ${stack} stack (down + up -d)"
remote "cd ${dir} && docker compose down && docker compose up -d"
fi
echo
remote "cd ${dir} && docker compose ps"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
#
# Rotate the LiveKit API-Key + Secret and restart the SFU. Prints the new
# values so you can paste them into the Supabase Edge-Function env (the
# mint-livekit-token function signs JWTs with them).
#
# Usage:
# ./scripts/prod/rotate-livekit-keys.sh
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
new_key="API$(openssl rand -hex 8)"
new_secret="$(openssl rand -hex 32)"
echo "new LiveKit credentials:"
echo " LIVEKIT_API_KEY=${new_key}"
echo " LIVEKIT_API_SECRET=${new_secret}"
echo
read -rp "write these into ${PROD_LIVEKIT_DIR}/livekit.yaml on the server? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
echo "aborted — nothing changed."
exit 1
fi
# Replace the `keys:` block (last non-empty section in our config).
remote "cd ${PROD_LIVEKIT_DIR} && \
python3 - <<'PY'
from pathlib import Path
p = Path('livekit.yaml')
lines = p.read_text().splitlines()
out = []
skip = False
for line in lines:
if line.strip().startswith('keys:'):
out.append('keys:')
out.append(f' ${new_key}: ${new_secret}')
skip = True
continue
if skip:
if line.startswith(' '):
continue
skip = False
out.append(line)
p.write_text('\n'.join(out) + '\n')
print('livekit.yaml updated')
PY"
remote "cd ${PROD_LIVEKIT_DIR} && docker compose restart livekit"
echo
echo "LiveKit restarted with new keys."
echo
echo "Next: push these to the Supabase Edge-Function env on the server:"
echo " edit ${PROD_SUPABASE_DIR}/.env → set LIVEKIT_API_KEY / LIVEKIT_API_SECRET"
echo " then: ./scripts/prod/restart.sh supabase functions"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
#
# Open an SSH tunnel that exposes prod Postgres on localhost:5433. Useful
# for running `psql`, `supabase db push`, or any GUI DB client against prod.
#
# The prod Postgres container only listens on the host's localhost:5432, so
# we need an SSH tunnel to reach it from the Mac. Tunnel stays open until
# Ctrl+C.
#
# Usage:
# ./scripts/prod/tunnel-db.sh
# # then in another terminal:
# psql -h localhost -p 5433 -U postgres -d postgres
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
local_port="${1:-5433}"
echo "tunnel: localhost:${local_port}${SSH_HOST}:localhost:5432"
echo "press Ctrl+C to close"
# shellcheck disable=SC2086
exec ssh ${SSH_OPTS} -N -L "${local_port}:localhost:5432" "${SSH_HOST}"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
#
# Open an SSH tunnel that exposes the Mailpit web UI on localhost:8025.
# Then browse to http://localhost:8025 on your Mac to see all emails the
# auth service has sent (magic-link confirmation, password reset, etc.).
#
# Usage:
# ./scripts/prod/tunnel-mailpit.sh
# # then open http://localhost:8025 in your browser.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${here}/config.sh"
local_port="${1:-8025}"
echo "tunnel: localhost:${local_port}${SSH_HOST}:localhost:8025"
echo "open http://localhost:${local_port} in your browser"
echo "press Ctrl+C to close"
# shellcheck disable=SC2086
exec ssh ${SSH_OPTS} -N -L "${local_port}:localhost:8025" "${SSH_HOST}"