Skip to content

Supabase backup

Back up OpenQuok Postgres and Storage, then freeze writes before the final dump for region cutover.

11 min read

Connect your agent today

Draft from chat, review in your calendar, and publish only what you approve.

Start for $0

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:

LayerWhat it coversWhere it runs
1Pro daily snapshots (database only)Supabase Dashboard
2Logical SQL dump (roles, schema, data)Supabase CLI on your machine
3Storage object exportNode 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)
  • psql from 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.

Backup layers

Layer 1 — Verify Pro daily snapshots

  1. Open Database → Backups in the Supabase Dashboard for your production project.
  2. Confirm that recent daily snapshots exist. On the Pro plan, retention is 7 days.
  3. 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/.

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.sql
  • schema.sql
  • data.sql
  • checksums.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:

BucketUse
avatarsProfile and integration photos
blog_imagesCMS blog images
listing_imagesExtensions 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-cutover

Phase 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

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.

CheckWhere
Google is enabled. Client secret is saved.Auth → Providers → Google
Site URL matches your frontendAuth → URL Configuration
Redirect URLs match the source projectAuth → URL Configuration
Email confirmation settings match the source projectAuth → Providers → Email (or Auth → Settings)
Leaked password protection matches your policyAuth → Settings

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_cron is 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.

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

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:

CheckExpected
Public blog / docsHTTP 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 logsProcess 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.

SurfaceFileKeys
Backend and workersbackend/.env.production.local, orchestrator/.env.production.localPUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_PUBLISHABLE_KEY, SUPABASE_SECRET_KEY
Webweb/.env.production.localVITE_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):

CheckHow
Google OAuth + email signupExisting user and a new user
Session refreshReload after login (expect one re-login; sessions invalidate)
WorkersEnqueue a scheduled post after freeze is off
IntegrationsOne provider OAuth
StripeSubscriptions 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:

  1. Pause the source project (rollback in that window is revert env + redeploy).
  2. Remove the source project Google OAuth callback URI.
  3. 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-cutover dump until write-freeze is live and verified on API, web, and workers.
  • Do not git add SQL dumps or exported Storage files. Run git status after 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.
Search documentation
Find a docs page
Discord Support