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):
This runs (in order):
- Downloads env files from Secret Manager (
just download-env all). - Calls Supabase Management API to ensure a branch exists for the current git ref (slug =
slugify(git_branch_name)). - Polls until the branch is healthy.
- 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_*, …)
- 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¶
| 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:
- Shell environment.
- Any
backend/.env.*file. - 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:
- Generates a 24-byte URL-safe random password.
- Calls
PATCH /v1/projects/{branch_ref}/database/passwordwith that value. - Persists the new password into
backend/.env.localasPOSTGRES_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_createdhook. - Synthetic Seed — what
just seed-branchputs 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.