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

The preview helper rebuilds newly created branches from the checkout's supabase/migrations/** after Supabase's initial replay of production's recorded migration SQL. Production changes missing from the checkout are not retained. Closing the PR, merged or not, deletes the preview; it never writes production (see lifecycle). Previews 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. Waits for provisioning and for Supabase's initial migration replay to settle, even if that replay failed.
  4. Preserves the branch's existing PostgREST configuration and exposes the schemas in REQUIRED_EXPOSED_SCHEMAS (currently backoffice).
  5. 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_*, …)
  6. Rebuilds a fresh preview's application schemas and migration history from the checkout's migration files. Reused previews are also rebuilt if their applied migration count is behind the checkout or unreadable; otherwise their schema and seeded data are preserved.
  7. Runs just seed-branch — applies any missing migrations, creates dev users, inserts synthetic data, refreshes materialized views.

After this, just dev <environment> from backend/ 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

# Apply migrations and re-seed after changing the seed function
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 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 initial replay] D --> R[helper rebuilds from Git migrations] R --> E[just seed-branch fills data] E --> F[develop locally] F --> G{PR closed?} G -- merged or not --> I[cleanup.yml: delete branch] G -- still open + idle 7d --> J[reaper.yml: delete]
Event Trigger Action
PR closed (merged or not) .github/workflows/supabase-branch-cleanup.yml Calls supabase_branch.py delete <head_ref> — just removes the preview; missing previews are a no-op. It never writes production: migrations reach the develop test database and production through the Supabase GitHub integration (see branch mapping).
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). Never touches the default (production) branch or persistent branches such as the develop test database.

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).

The script does not read the Supabase CLI's own credential store. If you are logged into the CLI but have no token in env or backend/.env.*, recover it without printing it (macOS — -g would dump the token to your terminal):

export SUPABASE_ACCESS_TOKEN=$(security find-generic-password -s "Supabase CLI" \
  -a access-token -w | sed 's/^go-keyring-base64://' | base64 -d)

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 Continue to migration-history checks.
Initial replay failed MIGRATIONS_FAILED Continue; a fresh or behind preview is rebuilt from Git.
Broken FAILED, REMOVED Existing branch: delete and recreate once.
Transient CREATING_PROJECT, COMING_UP, … Poll until terminal status.

If provisioning or the readiness wait fails for an existing branch, the helper deletes and recreates it once. MIGRATIONS_FAILED alone does not trigger deletion. The subsequent Git replay can still fail; inspect that error before retrying.

Required exposed schemas

After a branch becomes healthy, ensure reads GET /v1/projects/{branch_ref}/postgrest and appends any missing REQUIRED_EXPOSED_SCHEMAS through the corresponding PATCH endpoint. Existing schemas remain in their current order, and re-running ensure is a no-op when the required schemas are already present.

Add a schema to this list only when browser code must address it through supabase.schema(...). Production exposure remains an explicit Dashboard setting.

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.

captcha protection: request disallowed (no captcha_token found)

Preview branches inherit production's Auth settings — CAPTCHA (Attack Protection) and the password policy. None of it is database state, so no seed or migration can change it. Both the login form and the dev auto-login hit /auth/v1/token?grant_type=password with no captcha token, so every password sign-in on the branch is rejected.

Disable it on the branch project only (never production): Dashboard → branch project → Authentication → Attack Protection → CAPTCHA off. Headless equivalent:

curl -sS -X PATCH "https://api.supabase.com/v1/projects/<branch_ref>/config/auth" \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"security_captcha_enabled": false}' >/dev/null

Never GET that endpoint and print the response: it carries the inherited production Turnstile secret key, which then lands in your shell history or an agent transcript.

The same inheritance is why just seed-branch generates a random BACKOFFICE_ADMIN_PASSWORD instead of admin — prod's policy (min 8 + HIBP) rejects the weak seed password on a branch.

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.
REQUIRED_EXPOSED_SCHEMAS Custom PostgREST schemas appended to every preview branch (backoffice).
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 — essential agent safeguards and task links into these shared procedures.
  • docs/plans/seed-synthetic-data.md — original design doc.
  • docs/plans/seed-pgdump-study.md — performance study for the *-copy (real-data) path.