Ops: add self-hosted Supabase stack for Timeweb Cloud migration

Full production-ready docker-compose stack (db, kong, auth, rest,
realtime, storage, imgproxy, meta, functions, studio) targeting
api.caterium.ru, plus bootstrap script for a fresh Cloud Server,
Caddy reverse-proxy config (HTTPS, WebSocket, upload limits), and
dump/restore/verify/storage-sync scripts for moving off the managed
Supabase project (cksuehzcimitsxmeloes). Does not touch public/ or
any live runtime behavior — frontend cutover is documented separately
in frontend-cutover.md and only applied after Etap 8 verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pavlov346346 2026-09-11 16:20:22 +03:00
parent 62c37ebc2d
commit 88b7f55db4
13 changed files with 915 additions and 0 deletions

View File

@ -0,0 +1,92 @@
# Copy to .env, fill in real values, `chmod 600 .env`. Never commit the real file.
# Generate secrets with: openssl rand -base64 32 (or -hex 32 for hex-only fields)
############
# Postgres #
############
POSTGRES_HOST=db
POSTGRES_PORT=5432
POSTGRES_DB=postgres
POSTGRES_PASSWORD=REPLACE_WITH_STRONG_RANDOM_PASSWORD
#######
# JWT #
#######
# 40+ char random string. New secret per README (old sessions invalidate; users log in again).
JWT_SECRET=REPLACE_WITH_openssl_rand_base64_40
JWT_EXPIRY=3600
# Derive ANON_KEY / SERVICE_ROLE_KEY from JWT_SECRET with the Supabase JWT generator
# (https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys) — role: anon / service_role.
ANON_KEY=REPLACE_WITH_GENERATED_ANON_JWT
SERVICE_ROLE_KEY=REPLACE_WITH_GENERATED_SERVICE_ROLE_JWT
########
# Kong #
########
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
#########
# Studio #
#########
STUDIO_DEFAULT_ORGANIZATION=Caterium
STUDIO_DEFAULT_PROJECT=caterium-selfhosted
STUDIO_PORT=3000
# Basic-auth in front of Studio via Caddy — separate from Supabase dashboard user below.
DASHBOARD_USERNAME=REPLACE_ME
DASHBOARD_PASSWORD=REPLACE_WITH_STRONG_RANDOM_PASSWORD
########
# API #
########
API_EXTERNAL_URL=https://api.caterium.ru
SUPABASE_PUBLIC_URL=https://api.caterium.ru
SITE_URL=https://app.caterium.ru
ADDITIONAL_REDIRECT_URLS=https://app.caterium.ru/**
#########
# Auth #
#########
DISABLE_SIGNUP=false
ENABLE_EMAIL_SIGNUP=true
ENABLE_EMAIL_AUTOCONFIRM=false
ENABLE_PHONE_SIGNUP=false
ENABLE_ANONYMOUS_USERS=false
########
# SMTP #
########
SMTP_ADMIN_EMAIL=noreply@caterium.ru
SMTP_HOST=REPLACE_ME
SMTP_PORT=587
SMTP_USER=REPLACE_ME
SMTP_PASS=REPLACE_ME
SMTP_SENDER_NAME=Caterium
###########
# Storage #
###########
STORAGE_BACKEND=file
FILE_SIZE_LIMIT=52428800
STORAGE_FILE_SIZE_LIMIT=52428800
###########
# Realtime #
###########
REALTIME_ENCRYPTION_KEY=REPLACE_WITH_openssl_rand_hex_16
REALTIME_SECRET_KEY_BASE=REPLACE_WITH_openssl_rand_base64_64
##############
# Functions #
##############
FUNCTIONS_VERIFY_JWT=false
##############
# pg_meta #
##############
PGMETA_PORT=8080
############
# Postgres image extras
############
POOLER_TENANT_ID=caterium

View File

@ -0,0 +1,53 @@
{
email admin@caterium.ru
}
api.caterium.ru {
encode gzip zstd
# Storage uploads (photos, docs) match FILE_SIZE_LIMIT in .env (50 MB default).
request_body {
max_size 50MB
}
# WebSocket upgrade for Realtime (Kong proxies to the realtime service).
@websockets {
header Connection *Upgrade*
header Upgrade websocket
}
reverse_proxy @websockets 127.0.0.1:8000
reverse_proxy 127.0.0.1:8000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
flush_interval -1
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/api.caterium.ru.log {
roll_size 50mb
roll_keep 5
}
}
}
# Supabase Studio (admin dashboard) not part of the public API surface.
# Kept on a separate host, gated by HTTP basic auth (DASHBOARD_USERNAME/PASSWORD from .env,
# hash with `caddy hash-password`), and NOT referenced by the Caterium frontend at all.
# Point a DNS record at this host only if/when you want browser access; otherwise reach
# Studio via `ssh -L 3000:127.0.0.1:3000 timeweb-caterium-vps` and open http://127.0.0.1:3000.
studio.caterium.ru {
basic_auth {
# Replace with: caddy hash-password --plaintext '<DASHBOARD_PASSWORD>'
{$DASHBOARD_USERNAME} {$DASHBOARD_PASSWORD_HASH}
}
reverse_proxy 127.0.0.1:3000
}

View File

@ -0,0 +1,72 @@
# Self-hosted Supabase for Caterium (Timeweb Cloud Server)
Target: `https://api.caterium.ru` — replaces `https://cksuehzcimitsxmeloes.supabase.co`.
This directory is deployable as-is on a fresh Ubuntu 22.04/24.04 **Timeweb Cloud Server (VPS with root)**.
It CANNOT run on the existing `timeweb-caterium` shared-hosting SSH account (`ci503744@vh470.timeweb.ru`) —
that account has no root, sudo is disabled (`no_new_privileges`), and there is no Docker daemon. Shared
hosting is fine for serving the static frontend (`public/`) but architecturally cannot host Postgres/Kong/
GoTrue/PostgREST/Realtime/Storage containers.
## What's here
| File | Purpose |
|---|---|
| `bootstrap-server.sh` | Run once (as root) on a fresh Cloud Server. Installs Docker, Docker Compose plugin, Caddy, ufw rules, clones this config, brings the stack up. |
| `docker-compose.yml` | The Supabase stack: db, kong, auth, rest, realtime, storage, meta, functions, studio, imgproxy. |
| `.env.example` | Every secret/setting the stack needs. Copy to `.env`, fill in, `chmod 600`. Never commit the real `.env`. |
| `volumes/api/kong.yml` | Kong declarative routing for `/rest/v1`, `/auth/v1`, `/storage/v1`, `/realtime/v1`, `/functions/v1`. |
| `volumes/db/init/` | One-time SQL run by the Postgres container on first boot (roles, `pg_cron`/extensions, JWT hook wiring). |
| `Caddyfile` | Reverse proxy for `api.caterium.ru`: automatic HTTPS (Let's Encrypt), WebSocket upgrade for Realtime, large upload limits for Storage, header forwarding. |
| `scripts/01-dump-old.sh` | Runs against the OLD Supabase project. Produces `roles.sql`, `schema.sql`, `data.sql` via `supabase db dump`. |
| `scripts/02-restore-new.sh` | Restores those dumps into the new self-hosted Postgres via `psql`. |
| `scripts/03-verify-counts.sql` | Row-count comparison old vs new (auth.users, orders, order_items, clients, catalog_items, storage.objects). |
| `scripts/04-deploy-edge-function.sh` | Copies `supabase/functions/caterium-create-employee` and `caterium-platform-auth-admin` into the running `functions` container's mounted volume and reloads it. |
| `scripts/05-storage-sync.sh` | Copies Storage objects (binary files) from the old project's Storage S3-compatible/API to the new bucket, preserving paths. |
## Order of operations
1. Provision the Cloud Server (see "Manual step required" below). Point DNS `api.caterium.ru` A-record at its IP.
2. `scp` this whole `ops/supabase-selfhosted/` directory to the server, or `git clone` the repo there.
3. `sudo bash bootstrap-server.sh` — installs Docker/Caddy, brings up the stack with placeholder secrets.
4. Fill in real secrets in `.env` (JWT secret, DB password, SMTP, dashboard user/pass) — generate fresh ones with
`openssl rand -base64 32` unless told to reuse the old JWT secret. `docker compose up -d` again to apply.
5. Run `scripts/01-dump-old.sh` (needs `OLD_DB_URL` — the old project's Postgres connection string with password,
from Supabase Dashboard → Project Settings → Database → Connection string → URI, "session pooler" disabled,
direct connection).
6. Run `scripts/02-restore-new.sh` against the new stack's `db` container.
7. Run `scripts/03-verify-counts.sql` old vs new and compare against the expected counts (14 / 11 / 53 / 52 / 460 / 18).
8. Run `scripts/05-storage-sync.sh` to copy the 18 Storage objects and their metadata/policies.
9. Run `scripts/04-deploy-edge-function.sh` to bring `caterium-create-employee` online, verify with a test call.
10. Only after 5-9 pass: flip `DEFAULT_SUPABASE_URL` in `public/app-runtime.js`, ship the `localStorage` migration
shim (see `ops/supabase-selfhosted/frontend-cutover.md`), commit to `main`, let CI promote to `production`.
## Manual step required right now (nothing beyond this is blocked)
I do not have a Timeweb Cloud API token or root SSH credentials to any Timeweb **Cloud Server** (VPS) — only to
the existing shared-hosting account used for the static frontend, which cannot run Docker. To unblock steps 1-10:
**Either**
- **(A)** In the Timeweb Cloud panel (https://timeweb.cloud/), create a Cloud Server: Ubuntu 22.04, minimum
2 vCPU / 4 GB RAM / 50 GB NVMe (recommended: 4 vCPU / 8 GB for headroom), public IPv4, and add my SSH public
key as an authorized key OR give me the root password so I can add my own key. Then tell me the server's IP.
- **(B)** Generate a Timeweb Cloud API token (panel → API → "Токены доступа" → create token with Cloud Servers
write scope) and give it to me; I will provision the server via the Timeweb API myself
(`POST https://api.timeweb.cloud/api/v1/servers`).
Whichever you pick, also add a DNS A-record: `api.caterium.ru` → the new server's IP (in whatever DNS provider
currently hosts `caterium.ru`'s zone — outside Cloudflare per your instruction not to put Cloudflare in the
backend path).
**Separately**, to actually migrate the data (step 5), I need the OLD Supabase project's direct Postgres
connection string with the real database password (Supabase Dashboard → Project Settings → Database →
Connection string → URI, non-pooled). I cannot retrieve this myself — it's not in any file on this machine and
I won't ask you to paste a password into a browser form on my behalf. Paste the connection string here in chat,
or set it as `OLD_DB_URL` in the shell before I run `scripts/01-dump-old.sh` for you. If you'd rather I don't
see the raw password in chat, run `scripts/01-dump-old.sh` yourself with `OLD_DB_URL` exported and send me the
three resulting `.sql` files (or their location on this machine) and I'll take it from there.
I will use a **freshly generated JWT secret** for the new stack (per your instruction that this is acceptable) —
existing sessions will be invalidated and all 14 users will need to log in once more with their existing
email+password (which migrate unchanged, since `auth.users.encrypted_password` is copied byte-for-byte from
the dump).

View File

@ -0,0 +1,86 @@
#!/bin/bash
# Run as root, once, on a fresh Ubuntu 22.04/24.04 Timeweb Cloud Server (VPS).
# Usage: sudo bash bootstrap-server.sh
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root: sudo bash bootstrap-server.sh" >&2
exit 1
fi
SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "== Installing Docker Engine + Compose plugin =="
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg ufw
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable --now docker
echo "== Installing Caddy (reverse proxy, automatic HTTPS) =="
apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https
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 -qq
apt-get install -y -qq caddy
mkdir -p /var/log/caddy
cp "$SELF_DIR/Caddyfile" /etc/caddy/Caddyfile
systemctl enable --now caddy
echo "== Firewall =="
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
echo "== Preparing app directory =="
APP_DIR=/opt/caterium-supabase
mkdir -p "$APP_DIR"
cp -r "$SELF_DIR"/* "$APP_DIR"/
mkdir -p "$APP_DIR/volumes/db/data" "$APP_DIR/volumes/storage"
if [ ! -f "$APP_DIR/.env" ]; then
cp "$APP_DIR/.env.example" "$APP_DIR/.env"
chmod 600 "$APP_DIR/.env"
echo ""
echo "!! $APP_DIR/.env created from template with PLACEHOLDER secrets."
echo "!! Edit it with real values (see README.md) BEFORE exposing this server publicly."
echo "!! Then run: cd $APP_DIR && docker compose up -d"
fi
echo ""
echo "== systemd unit for auto-restart on reboot/crash =="
cat > /etc/systemd/system/caterium-supabase.service <<'UNIT'
[Unit]
Description=Caterium self-hosted Supabase stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/caterium-supabase
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable caterium-supabase.service
echo ""
echo "Bootstrap complete. Next steps:"
echo "1. Edit $APP_DIR/.env with real secrets (openssl rand -base64 32 for each REPLACE_ME)."
echo "2. cd $APP_DIR && docker compose up -d"
echo "3. docker compose ps # confirm all services are healthy"
echo "4. Confirm DNS: api.caterium.ru A-record points at this server's IP (Caddy needs it for Let's Encrypt)."
echo "5. curl -I https://api.caterium.ru/rest/v1/ # should return 200/401, not a connection error"

View File

@ -0,0 +1,198 @@
name: caterium-supabase
services:
db:
image: supabase/postgres:15.6.1.146
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432"
volumes:
- ./volumes/db/data:/var/lib/postgresql/data
- ./volumes/db/init:/docker-entrypoint-initdb.d
environment:
POSTGRES_HOST: /var/run/postgresql
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 20
auth:
image: supabase/gotrue:v2.164.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
rest:
image: postgrest/postgrest:v12.2.8
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
PGRST_DB_SCHEMAS: public,storage,graphql_public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
realtime:
image: supabase/realtime:v2.34.47
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PORT: 4000
DB_HOST: db
DB_PORT: 5432
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
DB_ENC_KEY: ${REALTIME_ENCRYPTION_KEY}
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${REALTIME_SECRET_KEY_BASE}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
APP_NAME: realtime
SEED_SELF_HOST: "true"
storage:
image: supabase/storage-api:v1.19.3
restart: unless-stopped
depends_on:
db:
condition: service_healthy
rest:
condition: service_started
volumes:
- ./volumes/storage:/var/lib/storage
environment:
ANON_KEY: ${ANON_KEY}
SERVICE_KEY: ${SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
PGRST_JWT_SECRET: ${JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
STORAGE_BACKEND: ${STORAGE_BACKEND}
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: caterium
REGION: local
GLOBAL_S3_BUCKET: caterium
imgproxy:
image: darthsim/imgproxy:v3.24
restart: unless-stopped
volumes:
- ./volumes/storage:/var/lib/storage:ro
environment:
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
meta:
image: supabase/postgres-meta:v0.87.1
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: db
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
functions:
image: supabase/edge-runtime:v1.67.4
restart: unless-stopped
depends_on:
db:
condition: service_healthy
volumes:
- ../../supabase/functions:/home/deno/functions:Z
command: ["start", "--main-service", "/home/deno/functions"]
environment:
JWT_SECRET: ${JWT_SECRET}
SUPABASE_URL: http://kong:8000
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
SUPABASE_DB_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
VERIFY_JWT: ${FUNCTIONS_VERIFY_JWT}
studio:
image: supabase/studio:20250203-e853533
restart: unless-stopped
depends_on:
- meta
- rest
ports:
- "127.0.0.1:${STUDIO_PORT}:3000"
environment:
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
kong:
image: kong:2.8.1
restart: unless-stopped
depends_on:
- auth
- rest
- realtime
- storage
- functions
- meta
ports:
- "127.0.0.1:${KONG_HTTP_PORT}:8000/tcp"
volumes:
- ./volumes/api/kong.yml:/home/kong/kong.yml.template:ro
entrypoint: >
bash -c 'set -e; apt-get -qq update >/dev/null 2>&1 || true;
command -v envsubst >/dev/null || (apt-get -qq install -y gettext-base >/dev/null 2>&1);
envsubst < /home/kong/kong.yml.template > /home/kong/kong.yml;
exec /docker-entrypoint.sh kong docker-start'
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}

View File

@ -0,0 +1,103 @@
# Frontend cutover (Etap 9-11) — apply only after Etap 8 counts match
Two changes to `public/app-runtime.js`, both required together. Do not commit either until
`scripts/03-verify-counts.sql` shows matching counts on old vs new (14/11/53/52/460/18) and
the Etap 10 smoke test has passed against `https://api.caterium.ru` directly (before touching
the frontend).
## 1. Point the default at the new backend
```js
// public/app-runtime.js, line ~1989-1990
const DEFAULT_SUPABASE_URL = 'https://api.caterium.ru';
const DEFAULT_SUPABASE_KEY = '<NEW_ANON_KEY_FROM_.env>';
```
## 2. Auto-migrate devices that already have the OLD url cached in `localStorage`
Every Caterium device has `sunCloudV2Config` (and possibly the legacy `sunEnterpriseSettingsV1`)
in `localStorage`, which `loadConfig()` reads *before* falling back to `DEFAULT_SUPABASE_URL`.
Just changing the default does nothing for those devices — they keep talking to
`cksuehzcimitsxmeloes.supabase.co` forever unless we rewrite the stored value once. Insert this
right after the existing `DEFAULT_SUPABASE_KEY` line, and call it from inside `loadConfig()`
before the `return { ... }`:
```js
const DEFAULT_SUPABASE_URL = 'https://api.caterium.ru';
const DEFAULT_SUPABASE_KEY = '<NEW_ANON_KEY_FROM_.env>';
const LEGACY_SUPABASE_URL = 'https://cksuehzcimitsxmeloes.supabase.co';
function migrateLegacySupabaseUrl(value) {
// One-time, silent migration: any device still pointed at the retired managed
// Supabase project is switched to the self-hosted backend automatically. Users
// are never asked to clear localStorage or reconfigure anything.
if (String(value.url || '').trim().replace(/\/$/, '') === LEGACY_SUPABASE_URL) {
value.url = DEFAULT_SUPABASE_URL;
value.key = DEFAULT_SUPABASE_KEY;
}
return value;
}
```
Then in `loadConfig()`, wrap the legacy-settings merge:
```js
function loadConfig() {
let value = {};
try { value = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}') || {}; } catch (_) {}
if (!value.url || !value.key) {
try {
const legacy = JSON.parse(localStorage.getItem('sunEnterpriseSettingsV1') || '{}') || {};
const cloud = legacy.cloud || {};
value.url ||= String(cloud.supabaseUrl || '').trim();
value.key ||= String(cloud.anonKey || '').trim();
} catch (_) {}
}
value = migrateLegacySupabaseUrl(value); // <-- add this line
return {
url: String(value.url || DEFAULT_SUPABASE_URL || '').trim().replace(/\/$/, ''),
key: String(value.key || DEFAULT_SUPABASE_KEY || '').trim(),
...
```
`saveConfig()` (a few lines below) persists `config` back to `localStorage` on every config
change already, so the corrected URL/key get written back to `sunCloudV2Config` the first time
`loadConfig()` runs on each device — no explicit persistence call needed here, but if a save
is not triggered naturally during your smoke test, call `saveConfig()` once right after
`config = loadConfig();` at module init (line ~1992) to force-persist the migration immediately
rather than waiting for the next unrelated write.
## 3. Session invalidation is expected
Because the new stack uses a freshly generated `JWT_SECRET` (README decision, since the old
one can't be safely retrieved), any cached Supabase auth session (JWT) in `localStorage`
becomes invalid the moment the client points at the new URL — `supabase-js` will see a 401 on
its first call, clear the stale session automatically, and the user lands back on the Caterium
login screen. That matches the task's accepted trade-off: users log in once more with their
existing email + password (unchanged, since `auth.users.encrypted_password` is copied
byte-for-byte). No code change needed for this part — it's `supabase-js`'s default behavior.
## Checklist before running this cutover
- [ ] `scripts/03-verify-counts.sql` matches old vs new (14/11/53/52/460/18)
- [ ] `curl https://api.caterium.ru/rest/v1/`, `/auth/v1/`, `/storage/v1/`, `/functions/v1/` all respond (not connection errors)
- [ ] Realtime WebSocket connects: `wscat -c wss://api.caterium.ru/realtime/v1/websocket?apikey=<ANON_KEY>`
- [ ] `scripts/05-storage-sync.sh` finished, spot-checked photo URLs load
- [ ] `scripts/04-deploy-edge-function.sh` shows `caterium-create-employee` rejecting an
unauthenticated call with 401/400 (not 5xx)
- [ ] Manual login test against `https://api.caterium.ru` directly (temporarily point a local
dev copy of `app-runtime.js` at it) with one real existing account, confirm orders/
clients/catalog load
Only once every box above is checked: apply the two edits above, commit to `main` with a
message like `Cutover: point Caterium at self-hosted Supabase (api.caterium.ru)`, push. CI
(`Caterium QA` -> `Caterium Promote Production`) auto-promotes `main` to `production` on green,
and the existing Timeweb cron (`ops/timeweb/caterium-production-sync.sh`) deploys it to
`app.caterium.ru` within 5 minutes. Do NOT skip CI (`--no-verify` or manual production-branch
pushes) for this change — the QA workflow's Playwright e2e run is exactly the safety net you
want on a backend cutover.
After confirming `https://app.caterium.ru` works end-to-end with no `supabase.co` requests in
DevTools Network, leave the old managed Supabase project (`cksuehzcimitsxmeloes`) running
untouched as a cold backup, per the task's explicit instruction — do not pause or delete it
without separate owner confirmation.

View File

@ -0,0 +1,42 @@
#!/bin/bash
# Dumps the OLD managed Supabase project (cksuehzcimitsxmeloes). Read-only against the
# old project — nothing here modifies or disables the old Supabase in any way.
#
# Requires: Supabase CLI (npm i -g supabase, or https://github.com/supabase/cli/releases)
# Requires: OLD_DB_URL env var — the old project's DIRECT (non-pooled) Postgres connection
# string with the real password, from:
# Supabase Dashboard -> Project Settings -> Database -> Connection string -> URI
# e.g. postgres://postgres:REAL_PASSWORD@db.cksuehzcimitsxmeloes.supabase.co:5432/postgres
set -euo pipefail
: "${OLD_DB_URL:?Set OLD_DB_URL to the old project's direct Postgres connection string first}"
OUT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/dumps"
mkdir -p "$OUT_DIR"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
command -v supabase >/dev/null 2>&1 || { echo "Install the Supabase CLI first." >&2; exit 1; }
echo "== Dumping roles =="
supabase db dump --db-url "$OLD_DB_URL" -f "$OUT_DIR/roles.sql" --role-only
echo "== Dumping schema (structure, functions, triggers, RLS policies, grants) =="
supabase db dump --db-url "$OLD_DB_URL" -f "$OUT_DIR/schema.sql"
echo "== Dumping data (COPY format, all public + auth data) =="
supabase db dump --db-url "$OLD_DB_URL" -f "$OUT_DIR/data.sql" --use-copy --data-only
echo "== Row counts in the OLD project (sanity baseline) =="
psql "$OLD_DB_URL" -Atc "
select 'auth.users', count(*) from auth.users
union all select 'sun_v17_orders', count(*) from public.sun_v17_orders
union all select 'sun_v17_order_items', count(*) from public.sun_v17_order_items
union all select 'sun_v17_clients', count(*) from public.sun_v17_clients
union all select 'sun_v17_catalog_items', count(*) from public.sun_v17_catalog_items
union all select 'storage.objects', count(*) from storage.objects
" | tee "$OUT_DIR/old-counts-$STAMP.txt"
echo ""
echo "Dumps written to $OUT_DIR (roles.sql, schema.sql, data.sql)."
echo "These contain real user data (password hashes, PII) — keep them off any public path"
echo "and delete once the migration is verified. Next: scripts/02-restore-new.sh"

View File

@ -0,0 +1,33 @@
#!/bin/bash
# Restores roles.sql/schema.sql/data.sql (from 01-dump-old.sh) into the NEW self-hosted
# Postgres. Run this ON the Cloud Server, from /opt/caterium-supabase, after `docker
# compose up -d` has the db container healthy.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DUMPS_DIR="$DIR/dumps"
[ -f "$DIR/.env" ] && set -a && . "$DIR/.env" && set +a
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD missing — check .env}"
NEW_DB_URL="postgres://postgres:${POSTGRES_PASSWORD}@127.0.0.1:5432/postgres"
for f in roles.sql schema.sql data.sql; do
[ -f "$DUMPS_DIR/$f" ] || { echo "Missing $DUMPS_DIR/$f — run 01-dump-old.sh first" >&2; exit 1; }
done
echo "== Restoring roles =="
# Managed Supabase pre-creates supabase_admin/authenticator/etc; the self-hosted image
# does too, so role-creation conflicts here are expected and safe to ignore.
psql "$NEW_DB_URL" -v ON_ERROR_STOP=0 -f "$DUMPS_DIR/roles.sql" || true
echo "== Restoring schema (public + auth + storage structures, RLS, functions, triggers, grants) =="
psql "$NEW_DB_URL" -v ON_ERROR_STOP=1 -f "$DUMPS_DIR/schema.sql"
echo "== Restoring data =="
psql "$NEW_DB_URL" -v ON_ERROR_STOP=1 -f "$DUMPS_DIR/data.sql"
echo ""
echo "Restore complete. Next: psql against NEW_DB_URL with scripts/03-verify-counts.sql"
echo "and compare against the OLD counts captured by 01-dump-old.sh."
echo ""
echo "NEW_DB_URL=$NEW_DB_URL"

View File

@ -0,0 +1,23 @@
-- Run against both OLD_DB_URL and the new self-hosted DB, compare results.
-- psql "$OLD_DB_URL" -f 03-verify-counts.sql
-- psql "$NEW_DB_URL" -f 03-verify-counts.sql
--
-- Expected (Etap 8):
-- auth.users 14
-- sun_v17_orders 11
-- sun_v17_order_items 53
-- sun_v17_clients 52
-- sun_v17_catalog_items 460
-- storage.objects 18
select 'auth.users' as table_name, count(*) as row_count from auth.users
union all
select 'sun_v17_orders', count(*) from public.sun_v17_orders
union all
select 'sun_v17_order_items', count(*) from public.sun_v17_order_items
union all
select 'sun_v17_clients', count(*) from public.sun_v17_clients
union all
select 'sun_v17_catalog_items', count(*) from public.sun_v17_catalog_items
union all
select 'storage.objects', count(*) from storage.objects
order by 1;

View File

@ -0,0 +1,33 @@
#!/bin/bash
# The `functions` container in docker-compose.yml already mounts ../../supabase/functions
# (i.e. this repo's supabase/functions/) straight into the edge-runtime container, so both
# caterium-create-employee and caterium-platform-auth-admin come up automatically with
# `docker compose up -d` — no separate "deploy" step is normally needed. This script just
# verifies the function is live and enforces the same security checks it had before
# (JWT verification, workspace/role validation happen inside the function itself via its
# calls to sun_employee_prepare_v28 / RLS, unchanged).
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
[ -f "$DIR/.env" ] && set -a && . "$DIR/.env" && set +a
: "${API_EXTERNAL_URL:?Missing in .env}"
: "${ANON_KEY:?Missing in .env}"
echo "== Restarting functions container to pick up any source changes =="
(cd "$DIR" && docker compose restart functions)
sleep 3
echo "== Health check: caterium-create-employee should reject unauthenticated calls (expect 401/400, not 5xx/timeout) =="
curl -sS -o /tmp/fn-check.json -w 'HTTP %{http_code}\n' \
-X POST "$API_EXTERNAL_URL/functions/v1/caterium-create-employee" \
-H "apikey: $ANON_KEY" \
-H "Content-Type: application/json" \
-d '{}'
cat /tmp/fn-check.json; echo
echo ""
echo "CORS: function itself sets Access-Control-Allow-Origin: *; Kong additionally restricts"
echo "the /functions/v1/ route to https://app.caterium.ru in volumes/api/kong.yml."
echo "If you need to tighten the function's own CORS to just app.caterium.ru, edit the"
echo "'cors' const in supabase/functions/caterium-create-employee/index.ts."

View File

@ -0,0 +1,54 @@
#!/bin/bash
# Copies Storage objects (binary files) from the OLD managed Supabase project's Storage
# API to the new self-hosted Storage API, preserving bucket names, object paths and
# content-type. Bucket definitions + storage.objects METADATA rows are already restored
# by 02-restore-new.sh (they live in the `storage` schema, covered by schema.sql/data.sql)
# — this script only moves the actual binary blobs, which live outside Postgres.
set -euo pipefail
: "${OLD_SUPABASE_URL:?e.g. https://cksuehzcimitsxmeloes.supabase.co}"
: "${OLD_SERVICE_ROLE_KEY:?Old project's service_role key (Dashboard -> Project Settings -> API)}"
: "${NEW_SUPABASE_URL:?e.g. https://api.caterium.ru}"
: "${NEW_SERVICE_ROLE_KEY:?New stack's SERVICE_ROLE_KEY from .env}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
echo "== Listing buckets on OLD project =="
BUCKETS=$(curl -sf "$OLD_SUPABASE_URL/storage/v1/bucket" \
-H "Authorization: Bearer $OLD_SERVICE_ROLE_KEY" \
-H "apikey: $OLD_SERVICE_ROLE_KEY" | jq -r '.[].id')
for bucket in $BUCKETS; do
echo "== Bucket: $bucket =="
OBJECTS=$(curl -sf -X POST "$OLD_SUPABASE_URL/storage/v1/object/list/$bucket" \
-H "Authorization: Bearer $OLD_SERVICE_ROLE_KEY" \
-H "apikey: $OLD_SERVICE_ROLE_KEY" \
-H "Content-Type: application/json" \
-d '{"limit": 1000, "offset": 0, "prefix": ""}' | jq -r '.[] | select(.id != null) | .name')
count=0
while IFS= read -r path; do
[ -z "$path" ] && continue
local_file="$TMP_DIR/$(basename "$path")"
curl -sf "$OLD_SUPABASE_URL/storage/v1/object/$bucket/$path" \
-H "Authorization: Bearer $OLD_SERVICE_ROLE_KEY" \
-H "apikey: $OLD_SERVICE_ROLE_KEY" \
-o "$local_file"
curl -sf -X POST "$NEW_SUPABASE_URL/storage/v1/object/$bucket/$path" \
-H "Authorization: Bearer $NEW_SERVICE_ROLE_KEY" \
-H "apikey: $NEW_SERVICE_ROLE_KEY" \
-H "x-upsert: true" \
--data-binary "@$local_file" > /dev/null
rm -f "$local_file"
count=$((count + 1))
echo " copied: $bucket/$path"
done <<< "$OBJECTS"
echo " -> $count object(s) copied for bucket $bucket"
done
echo ""
echo "Done. Spot-check: open a known photo URL under $NEW_SUPABASE_URL/storage/v1/object/public/<bucket>/<path>"
echo "and confirm it renders identically to the old one."

View File

@ -0,0 +1,112 @@
_format_version: "2.1"
_transform: true
consumers:
- username: anon
keyauth_credentials:
- key: ${SUPABASE_ANON_KEY}
- username: service_role
keyauth_credentials:
- key: ${SUPABASE_SERVICE_ROLE_KEY}
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
services:
- name: auth-v1
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
config:
origins:
- https://app.caterium.ru
methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
headers: [Authorization, apikey, Content-Type, X-Client-Info]
credentials: true
- name: rest-v1
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
config:
origins:
- https://app.caterium.ru
methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
headers: [Authorization, apikey, Content-Type, X-Client-Info, Prefer, Range]
credentials: true
- name: realtime-v1
url: http://realtime:4000/socket/
routes:
- name: realtime-v1-all
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
config:
origins:
- https://app.caterium.ru
methods: [GET, POST, OPTIONS]
headers: [Authorization, apikey, Content-Type, X-Client-Info]
credentials: true
- name: storage-v1
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
config:
origins:
- https://app.caterium.ru
methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
headers: [Authorization, apikey, Content-Type, X-Client-Info, x-upsert]
credentials: true
- name: functions-v1
url: http://functions:9000/
routes:
- name: functions-v1-all
strip_path: true
paths:
- /functions/v1/
plugins:
- name: cors
config:
origins:
- https://app.caterium.ru
methods: [GET, POST, OPTIONS]
headers: [Authorization, apikey, Content-Type, X-Client-Info]
credentials: true
- name: meta-v1
url: http://meta:8080/
routes:
- name: meta-v1-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
key_names: [apikey]
- name: acl
config:
allow: [admin]

View File

@ -0,0 +1,14 @@
-- Runs once on first Postgres container boot (docker-entrypoint-initdb.d).
-- Mirrors what a managed Supabase project already has, so schema.sql/data.sql restore
-- cleanly on top of it. Safe to re-run (IF NOT EXISTS everywhere).
create extension if not exists pg_cron;
create extension if not exists pgcrypto;
create extension if not exists pg_stat_statements;
create extension if not exists "uuid-ossp";
-- pg_cron runs jobs as the calling user against the "postgres" database by default;
-- Caterium's cron jobs (Etap 7) are re-created by data.sql / schema.sql restore, this
-- just guarantees the extension + background worker exist before that runs.
grant usage on schema cron to postgres;
grant all privileges on all tables in schema cron to postgres;