Skip to content

Supabase Preview Branches

Each git feature branch can get its own isolated Supabase Postgres branch — independent DB, independent URL + API keys, automatically destroyed when the matching PR closes.

This page is the developer reference for the ./bootstrap.py --branch flow, the lifecycle workflows, and troubleshooting.

When to use a preview branch

Use case Recommended target
Editing migrations / RLS / RPCs Preview branch (you need a real DB to test against)
Building UI against schema-stable data Local Supabase (cd supabase && just dev) is faster
Reproducing a real client bug Either, with just seed-branch-copy / just seed-local-copy to pull prod data
Pure-frontend changes None — your branch can talk to staging

Preview branches automatically replay every migration under supabase/migrations/** on creation and merge their migrations into production on PR merge. They are not a copy of production data — they start empty and rely on just seed-branch (synthetic) or just seed-branch-copy (real-prod copy) to populate.

Create a preview branch

From any non-protected git ref (develop / main / master are refused):

./bootstrap.py --branch

This runs (in order):

  1. Downloads env files from Secret Manager (just download-env all).
  2. Calls Supabase Management API to ensure a branch exists for the current git ref (slug = slugify(git_branch_name)).
  3. Polls until the branch is healthy.
  4. Writes credentials into:
    • backend/.env.local (SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, POSTGRES_*)
    • frontend/.env.local + frontend/.env.development.local (VITE_SUPABASE_URL, VITE_DIRECT_SUPABASE_*, …)
  5. Runs just seed-branch — applies any missing migrations, creates dev users, inserts synthetic data, refreshes materialized views.

After this, cd backend && just dev and cd frontend && just dev talk to your branch DB, not production Supabase.

Day-to-day commands

# Create / refresh a branch + seed it (idempotent — safe to re-run)
./bootstrap.py --branch

# Re-seed synthetic data only (after editing seed_synthetic/*.sql)
cd supabase && just seed-branch

# Wipe + re-seed synthetic data
cd supabase && just seed-branch --clear

# Pull real production data instead (slow, contains PII)
cd supabase && just seed-branch-copy

# Manage branches directly
python3.12 scripts/supabase_branch.py ensure
python3.12 scripts/supabase_branch.py delete <git-branch-name>
python3.12 scripts/supabase_branch.py merge  <git-branch-name>
python3.12 scripts/supabase_branch.py reap            # delete orphans (dry-run with --dry-run)

supabase db push against a preview branch is safe — branches are isolated. The just seed-branch target already does this for you, but you can also push manually:

cd supabase
set -a; . ../backend/.env.local; set +a
DB_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
supabase db push --db-url "$DB_URL" --include-all --yes

Lifecycle

flowchart LR A[git push feature/X] --> B[./bootstrap.py --branch] B --> C[Branch CREATED] C --> D[supabase replays migrations] D --> E[just seed-branch fills data] E --> F[develop locally] F --> G{PR closed?} G -- merged --> H[cleanup.yml: merge migrations to prod + delete branch] G -- not merged --> I[cleanup.yml: delete branch] G -- still open + idle 7d --> J[reaper.yml: delete]
Event Trigger Action
PR closed (merged) .github/workflows/supabase-branch-cleanup.yml Calls supabase_branch.py merge <head_ref> — applies branch migrations to production, then deletes the branch.
PR closed (not merged) Same workflow Calls supabase_branch.py delete — just removes the branch.
Nightly 03:00 UTC .github/workflows/supabase-branch-reaper.yml Calls supabase_branch.py reap — deletes branches whose upstream git ref is gone, idle ≥ 7d with no open PR, or older than 30d (hard cap).

scripts/supabase_branch.py internals

Token resolution

SUPABASE_ACCESS_TOKEN is looked up in this order, first match wins:

  1. Shell environment.
  2. Any backend/.env.* file.
  3. GCP Secret Manager (gcloud secrets versions access latest --secret=SUPABASE_ACCESS_TOKEN).

When all three fail, the script exits with code 2 and a multi-line banner:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✗ SUPABASE_ACCESS_TOKEN missing — cannot reach Supabase Management API.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Fix one of:
  1. cd backend && just download-env all
  2. export SUPABASE_ACCESS_TOKEN=<token>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Auto-recreate broken branches

The branch lifecycle uses these statuses (subset of what the Supabase API returns):

Set Statuses Behavior
Healthy ACTIVE_HEALTHY, ACTIVE, FUNCTIONS_DEPLOYED Reuse.
Broken FAILED, REMOVED, MIGRATIONS_FAILED Auto-delete + recreate.
Transient CREATING_PROJECT, COMING_UP, … Poll until terminal status.

If the Supabase API ever returns a "broken" status for an existing branch, the next ./bootstrap.py --branch / supabase_branch.py ensure call deletes it and creates a fresh one — no manual intervention needed.

Empty db_pass recovery

The Supabase Management API does not reliably return the branch DB password in the create payload (it sometimes comes back as an empty string). When that happens, the script:

  1. Generates a 24-byte URL-safe random password.
  2. Calls PATCH /v1/projects/{branch_ref}/database/password with that value.
  3. Persists the new password into backend/.env.local as POSTGRES_PASSWORD.

This is logged as Resetting DB password for branch <ref> (API returned empty db_pass). Subsequent psql connections use the persisted password.

Common errors

❌ POSTGRES_PASSWORD is empty in backend/.env.local

The branch was created but the API returned empty db_pass and the password-reset path didn't fire (e.g. you ran an old version of supabase_branch.py). Run ./bootstrap.py --branch again — the latest version always resets when empty.

❌ Branch DB is not provisioned: public.clients does not exist

The branch is alive but migrations didn't replay. Two recovery paths:

# Option 1: let bootstrap recreate it
python3.12 scripts/supabase_branch.py delete $(git rev-parse --abbrev-ref HEAD)
./bootstrap.py --branch

# Option 2: push migrations manually
cd supabase
set -a; . ../backend/.env.local; set +a
DB_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
supabase db push --db-url "$DB_URL" --include-all --yes

Access Restricted: admin@admin.com does not have access

The browser holds a JWT signed by a previous (deleted) branch's secret. Clear site data for localhost:3002 (or whichever port the backoffice runs on) and log in again — the new JWT will be signed by the current branch's secret.

Invalid login credentials

seed.sql didn't run on the branch (auth users missing). Re-run cd supabase && just seed-branch — that runs seed.sql as part of the flow.

Configuration

Env var Purpose
SUPABASE_ACCESS_TOKEN Personal token from https://supabase.com/dashboard/account/tokens. Stored in rose-backend-env-* Secret Manager blobs.
SUPABASE_PROJECT_REF Override the production project ref (defaults to Rose production).
PROTECTED_BRANCHES Hard-coded in scripts/supabase_branch.py{develop, main, master, HEAD}.
CREATE_POLL_TIMEOUT_S 300s. How long ensure waits for branch to become healthy.
REAPER_STALE_DAYS 7d idle threshold for reaper.
REAPER_HARD_CAP_DAYS 30d max age cap.

See also

  • Supabase Setup — Auth flow, RLS architecture, before_user_created hook.
  • Synthetic Seed — what just seed-branch puts in the DB.
  • supabase/AGENTS.md — same content tuned for AI coding agents.
  • docs/plans/seed-synthetic-data.md — original design doc.
  • docs/plans/seed-pgdump-study.md — performance study for the *-copy (real-data) path.