Supabase backup
Back up OpenQuok Postgres and Storage, then freeze writes before the final dump for region cutover.
Connect your agent today
Draft from chat, review in your calendar, and publish only what you approve.
Overview
OpenQuok stores user data in Supabase Postgres and in Supabase Storage buckets. A database dump does not include Storage object bytes. Run a full backup before you squash migrations, change regions, or run a maintenance cutover.
For region cutover, freeze writes before the final dump so users and workers cannot mutate Postgres while you copy data. Public marketing, blog, and docs pages stay live. See Cutover freeze runbook.
The repo provides three backup layers:
| Layer | What it covers | Where it runs |
|---|---|---|
| 1 | Pro daily snapshots (database only) | Supabase Dashboard |
| 2 | Logical SQL dump (roles, schema, data) | Supabase CLI on your machine |
| 3 | Storage object export | Node script on your machine |
Dump output and exported files contain PII. They live under .backups/ and stay gitignored. Only .backups/README.md is tracked in git.
Scripts live in scripts/prod-backup/.
Prerequisites
You need these tools before you run Layer 2:
- Supabase CLI (use
npx supabase@latest) - Docker Desktop (required by
supabase db dump) psqlfrom PostgreSQL 17 (for restore during cutover)
For Layer 3, the export script reads PUBLIC_SUPABASE_URL and SUPABASE_SECRET_KEY from backend/.env.production.local.
PII and secrets
Do not commit files under .backups/. Do not paste database passwords or connection strings into committed docs or scripts.
SUPABASE_SECRET_KEY bypasses Row Level Security. Use it only on a trusted machine for backup export.
Backup layers
Layer 1 — Verify Pro daily snapshots
- Open Database → Backups in the Supabase Dashboard for your production project.
- Confirm that recent daily snapshots exist. On the Pro plan, retention is 7 days.
- Note the date of the latest snapshot.
Record the check with:
node scripts/prod-backup/verify-layer1.mjs --latest-snapshot YYYY-MM-DD This writes layer1-verification.json into .backups/YYYYMMDD/.
Dashboard backups are DB-only
Layer 2 — CLI logical dump
Link your production project from backend/ (see Database & migrations), then run:
node scripts/prod-backup/dump-database.mjs --linked The script writes these files into .backups/YYYYMMDD/:
roles.sqlschema.sqldata.sqlchecksums.txt
If backend/ is not linked, set a session-pooler URL and run the dump without —linked:
export OLD_DB_URL='postgresql://postgres.your-project-ref:[PASSWORD]@aws-0-region.pooler.supabase.com:5432/postgres'
node scripts/prod-backup/dump-database.mjs Get the password from Database → Settings in the dashboard. Get the pooler host from Connect → Session pooler.
After the dump, run git status. Git must not list .backups/*.sql.
Layer 3 — Storage export
Export these buckets before cutover:
| Bucket | Use |
|---|---|
avatars | Profile and integration photos |
blog_images | CMS blog images |
listing_images | Extensions Hub images |
Run:
node scripts/prod-backup/export-storage.mjs Objects are saved under .backups/YYYYMMDD/storage/. A manifest.json file lists counts per bucket.
Composer media on Cloudflare R2 (media.openquok.com) is separate. Back up R2 separately if you need full media parity.
Run all three layers
To run Layers 1–3 into the same dated directory:
node scripts/prod-backup/run-initial-backup.mjs --latest-snapshot YYYY-MM-DD pnpm shortcuts:
pnpm prod-backup:verify
pnpm prod-backup:dump
pnpm prod-backup:storage
pnpm prod-backup:initial
pnpm prod-backup:restore
pnpm prod-backup:migrate-storage
pnpm prod-backup:rehearse
pnpm prod-backup:relink
pnpm prod-backup:smoke Copy dumps off site
Copy the dated directory to encrypted private storage. Keep backups for at least 90 days. Keep a copy past project decommission if you may need audit or rollback data.
Run a fresh backup immediately before a maintenance cutover. Enable write-freeze first, then use a suffix so the folder name is unique (see Cutover freeze runbook):
pnpm prod-backup:initial --latest-snapshot YYYY-MM-DD --suffix -pre-cutoverPhase B0 — Target project in the new region
Create the cutover target project in the new region before you run the maintenance restore. The source (current production) project stays live until cutover. This phase does not change production environment variables.
Pick a region close to your API and workers. Enable Integrations → Cron (pg_cron) on the target project before you restore data.
Automated setup
Run from the repo root:
pnpm prod-backup:create-us-project Pass the target project ref when the project already exists:
pnpm prod-backup:create-us-project --project-ref YOUR_TARGET_PROJECT_REF To copy auth settings from the source project through the Management API, set a Personal Access Token with auth config read and write permissions:
SUPABASE_ACCESS_TOKEN=sbp_... pnpm prod-backup:create-us-project --project-ref YOUR_TARGET_PROJECT_REF Personal Access Token and the CLI
If the command fails with a privileges error, the token may not list projects or run SQL. Unset the token and finish keys and pg_cron only:
unset SUPABASE_ACCESS_TOKEN
pnpm prod-backup:create-us-project --project-ref YOUR_TARGET_PROJECT_REF --skip-auth
Then complete auth in the dashboard, or create a token with broader project read access.
The script writes a migration manifest under .backups/ (gitignored). It records the target project ref, pooler host, and API keys.
Manual verification checklist
Complete these checks after the script runs. Do not update backend/.env.production.local, web/.env.production.local, or Vercel until cutover.
Verify auth on the target project
Open Authentication in the Supabase Dashboard for the target project.
| Check | Where |
|---|---|
| Google is enabled. Client secret is saved. | Auth → Providers → Google |
| Site URL matches your frontend | Auth → URL Configuration |
| Redirect URLs match the source project | Auth → URL Configuration |
| Email confirmation settings match the source project | Auth → Providers → Email (or Auth → Settings) |
| Leaked password protection matches your policy | Auth → Settings |
Resend is not Supabase SMTP
OpenQuok sends mail through the backend Resend HTTPS API (RESEND_SECRET_KEY, SENDER_EMAIL_ADDRESS). See Email (Resend). You do not configure Resend under Supabase Auth → SMTP.
If auth settings are missing, copy them from the source project dashboard or re-run the script with SUPABASE_ACCESS_TOKEN (without —skip-auth).
Add the Google Cloud redirect URI
In Google Cloud Console → OAuth client → Authorized redirect URIs, add the target project callback:
https://YOUR_TARGET_PROJECT_REF.supabase.co/auth/v1/callback Keep the source project callback until you decommission the old project:
https://YOUR_SOURCE_PROJECT_REF.supabase.co/auth/v1/callback See Google OAuth.
Confirm API key format
OpenQuok expects sb_publishable_… and sb_secret_…, not legacy JWT keys. Copy both from target project → Settings → API Keys. Update the migration manifest if the script stored legacy eyJ… keys.
Complete the B0 checklist
- Target project exists in the new region (correct plan and compute size)
pg_cronis enabled on the target project- API keys are recorded (
sbpublishable…/sbsecret…) - Auth is verified (Google secret, URL config, email confirmation, leaked-password protection)
- Google OAuth redirect URI for the target project is added in Google Cloud Console
- Production env still points at the source project
Optional — redirect URL wildcard
The target project may list a wildcard redirect URL (for example https://YOUR_BACKEND_DOMAIN/**) in addition to the exact callback path. The source project may list only the exact URL. You can remove the wildcard to match the source project. Add it back if OAuth redirects fail. See Google OAuth.
Optional — rehearsal on the throwaway target
Before the maintenance window, restore a Layer 2 dump into the B0 target project and dry-run Storage migration (list source/target object counts without uploading bytes):
# backend/ must be linked to the throwaway target project
pnpm prod-backup:rehearse --linked --backup-dir .backups/20260918 Roles and schema restore through the linked Supabase CLI. data.sql loads through psql (COPY format). Without NEW_DB_URL or SUPABASE_TARGET_DB_PASSWORD, the script resets the target database password via the Management API (requires SUPABASE_ACCESS_TOKEN with database_config_write).
The script writes .backups/us-migration/rehearsal-report.json (gitignored). To copy Storage objects during rehearsal (not recommended on production source), add —migrate-storage —yes.
Restore only:
export NEW_DB_URL='postgresql://postgres.<target-ref>:[PASSWORD]@<pooler-host>:5432/postgres'
pnpm prod-backup:restore --backup-dir .backups/20260918 --manifest-dir .backups/us-migration Storage dry-run only:
pnpm prod-backup:rehearse --skip-restore Cutover freeze runbook
Use this sequence for the live region cutover (~30–60 minutes). Flag behavior, env vars, and the operator bypass header are on Maintenance mode. Official restore notes: Backup and Restore using the CLI.
Freeze writes before the final dump
Set MAINTENANCE_MODE=freeze_writes on the API, web, and workers, redeploy, and verify the freeze before you run the -pre-cutover backup. Dumping first leaves a window where users, MCP/CLI, and BullMQ jobs can still write to the source database.
Do not squash migrations, refactor schema, or run security SQL during this window.
Operator timeline
T-0 MAINTENANCE_MODE=freeze_writes (Vercel backend + web + Railway workers; redeploy)
T+2m Verify: /blog → 200; /sign-in → /maintenance; POST /api/v1/... → 503
T+5m pnpm prod-backup:initial --latest-snapshot YYYY-MM-DD --suffix -pre-cutover
T+10m pnpm prod-backup:restore --backup-dir .backups/YYYYMMDD-pre-cutover
T+30m pnpm prod-backup:migrate-storage --yes
T+35m SQL: replace source Storage URLs in blog_posts.content (if needed)
T+40m Env cutover (target URL + keys); Vercel/Railway redeploy
T+42m pnpm prod-backup:relink
T+50m pnpm prod-backup:smoke (then manual Google / scheduled post / provider OAuth)
T+55m MAINTENANCE_MODE=off; redeploy; resume workers Workers
The worker process exits on startup when the flag is freeze_writes. As a backup, scale Railway worker services to 0 for the window so a missed env var cannot consume jobs.
Enable write-freeze and verify
Set MAINTENANCE_MODE=freeze_writes on the Vercel backend, Vercel web (server env, not a VITE_* variable), and Railway workers (or recreate self-host API, web, and worker containers). Redeploy all three.
Confirm before you dump:
| Check | Expected |
|---|---|
| Public blog / docs | HTTP 200 (SEO pages stay live) |
| /sign-in (and other auth/app routes) | Redirect to /maintenance |
Mutation such as POST /api/v1/… | 503 with Retry-After |
| Worker logs | Process exits without consuming BullMQ jobs |
Optional API smoke during freeze: send X-Maintenance-Bypass when MAINTENANCE_BYPASS_SECRET is set. See Maintenance mode.
Take the pre-cutover backup
With writes frozen, re-run Layers 1–3 into a unique directory:
pnpm prod-backup:initial --latest-snapshot YYYY-MM-DD --suffix -pre-cutover Then git status. Git must not list .backups/*.sql. Copy the dated directory to encrypted private storage.
Restore the dump into the target
export NEW_DB_URL='postgresql://postgres.<target-ref>:[PASSWORD]@<pooler-host>:5432/postgres'
pnpm prod-backup:restore --backup-dir .backups/YYYYMMDD-pre-cutover The restore script strips cli_login_postgres role lines and comments supabase_admin owner lines. If you restore by hand with psql, set session_replication_role = replica before loading data.sql (see the official backup-restore guide).
Migrate Storage and rewrite blog URLs
Source keys come from the current production env. Target keys come from .backups/migration/project.json (or the B0 rehearsal manifest).
export OLD_PROJECT_URL='https://<source-ref>.supabase.co'
export OLD_PROJECT_SERVICE_KEY='sb_secret_...'
export NEW_PROJECT_URL='https://<target-ref>.supabase.co'
export NEW_PROJECT_SERVICE_KEY='sb_secret_...'
pnpm prod-backup:migrate-storage --yes Add —dry-run first to list object counts without uploading.
If blog HTML still points at the source project host, rewrite in the target SQL editor:
UPDATE public.blog_posts
SET content = replace(content, 'https://<source-ref>.supabase.co', 'https://<target-ref>.supabase.co')
WHERE content LIKE '%<source-ref>.supabase.co%'; Cut over environment variables
Update these keys to the target project, then rebuild and redeploy.
| Surface | File | Keys |
|---|---|---|
| Backend and workers | backend/.env.production.local, orchestrator/.env.production.local | PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY |
| Web | web/.env.production.local | VITE_PUBLIC_SUPABASE_URL (baked at build time) |
pnpm vercel:env:sync:backend:prod
pnpm vercel:env:sync:web:prod
pnpm vercel:deploy:backend:prod
pnpm vercel:deploy:web:prod Redeploy Railway workers with the same target keys. Keep MAINTENANCE_MODE=freeze_writes until smoke tests pass.
Point the CLI at the target and repair migration history if the aggregated date changed. The script reads the target ref from .backups/us-migration/project.json and the date from backend/supabase/migrations/*_core_structure.sql. It does not run db push — restore already applied schema; repair only updates the history table.
pnpm prod-backup:relink Add —dry-run to print the target ref and any versions that would be marked applied. See Production — Supabase production migrations for the manual migration list / repair commands.
Smoke test, then resume writes
Run the automated Phase B4 checks against the target project (current PUBLIC_SUPABASE_URL):
pnpm prod-backup:smoke The script writes .backups/us-migration/smoke-report.json (gitignored). It verifies env alignment, user/post/integration counts, cloud_trial_consumed_at and billing rows, leftover Storage hosts, pg_cron, Database Linter RPC exposure, publishable-key denial of internal_* RPCs, public blog/health HTTP, and that API writes are not frozen (not 503). Add —skip-http for SQL-only. Add —repair-cron if the refresh-token job is missing after restore. Add —open-dashboard to open Advisors → Security.
Complete these manual checks (the script prints the same list):
| Check | How |
|---|---|
| Google OAuth + email signup | Existing user and a new user |
| Session refresh | Reload after login (expect one re-login; sessions invalidate) |
| Workers | Enqueue a scheduled post after freeze is off |
| Integrations | One provider OAuth |
| Stripe | Subscriptions unchanged |
When automated and manual checks pass, set MAINTENANCE_MODE=off on API, web, and workers; redeploy; restore Railway worker replicas if you scaled them to 0.
Pause the source project
After the target has been production for 7–14 days:
- Pause the source project (rollback in that window is revert env + redeploy).
- Remove the source project Google OAuth callback URI.
- Pause or delete the source project so you are not billed for two computes.
What you do not need to change
R2 (media.openquok.com), Redis, Railway/Vercel region, Neon, Meta/Stripe/social redirect URIs, and the Google OAuth client ID/secret stay the same. You only add the target Supabase Auth callback URI (done in Phase B0).
What not to do
- Do not take the
-pre-cutoverdump until write-freeze is live and verified on API, web, and workers. - Do not
git addSQL dumps or exported Storage files. Rungit statusafter every dump. - Do not skip Layer 3. Blog posts and avatars break after restore if object bytes are missing.
- Do not squash migrations, refactor schema, or apply security SQL in the same window as region cutover. Squash first, verify locally, then cut over with a frozen dump.
- Do not leave freeze mode on after smoke tests. Set MAINTENANCE_MODE to off and redeploy, or public app writes stay blocked.