Skip to content

Staging Database (Supabase Branch)

Staging runs on a persistent Supabase branch named staging, a child of the production project. It was created on 2026-07-29 with a full production data clone, under the pre-launch ruling that production data is effectively the founders' own (W4 of the environments program; as-built record in docs/planning/plans/2026-07-27-environments-and-delivery-pipeline.md).

FactValue
Branch namestaging
Branch project idncjbvoyalhkgqdvzltqh
Parent (production)cmhnfgvbfrkgayrrgrmf
Region / computeeu-north-1 / Micro (billed hourly, outside the spend cap)
Created withsupabase branches create staging --persistent --with-data --size micro --region eu-north-1
Per-branch config[remotes.staging] in supabase/config.toml (matched by project id)
Hyperdrivepodcasterplus-supabase-staging, direct origin, --origin-connection-limit 20

Persistent branches never auto-pause and are billed for as long as they exist.

How staging is addressed (and how it is not)

The linked project is production and stays production. supabase db push --linked, pnpm run db:types and supabase migration list all mean production, gated by the push checklist in supabase/CLAUDE.md. Never supabase link to the branch.

Anything aimed at staging says so explicitly with the branch's connection string, which is a secret (operator terminal or CI secret only):

bash
# BRANCH CREDENTIALS: `supabase branches get` REDACTS passwords in every output
# format (literal ****** — discovered 2026-07-29), so the working flow is:
# reset the branch DB password in the dashboard (staging branch -> Settings ->
# Database; touches only the branch), then:
read -s 'STAGING_DB_PASSWORD?Paste the staging DB password: ' && export STAGING_DB_PASSWORD
export STAGING_SESSION_URL=$(python3 -c "import os; from urllib.parse import quote; print('postgresql://postgres.ncjbvoyalhkgqdvzltqh:'+quote(os.environ['STAGING_DB_PASSWORD'],safe='')+'@aws-0-eu-north-1.pooler.supabase.com:5432/postgres')")
export STAGING_DIRECT_URL=$(python3 -c "import os; from urllib.parse import quote; print('postgresql://postgres:'+quote(os.environ['STAGING_DB_PASSWORD'],safe='')+'@db.ncjbvoyalhkgqdvzltqh.supabase.co:5432/postgres')")

supabase db push --db-url "$STAGING_SESSION_URL"                     # apply new migrations
SUPABASE_DB_URL="$STAGING_SESSION_URL" pnpm run db:verify --remote   # verify staging matches the files

supabase branches get outputs nothing but credential-shaped values (database URLs, service-role key, JWT secret) — treat its entire output as secret — but the passwords inside are REDACTED (******), so it cannot be sourced for a working connection; use the dashboard password-reset flow above. The direct host db.<ref>.supabase.co resolves to IPv6 only; the session pooler URL is the IPv4 path (its username carries the project id), and the Hyperdrive origin keeps the direct URL because Cloudflare connects server-side over IPv6.

Migration lanes (C5)

  • Staging: receives migrations automatically on merge to main once the W6 GitHub Actions pipeline exists. Until then, the explicit --db-url push above is the staging lane.
  • Production: unchanged. The six-step checklist in supabase/CLAUDE.md remains the only production path.

Seeding is disabled for the branch ([remotes.staging.db.seed] enabled = false): the production clone is the seed, and seed.sql is written for an empty local stack.

Post-clone credential scrub (run after every clone)

A production data clone carries credentials for external systems that staging must not store. The W3 outbound egress policy stops staging using them; the scrub stops staging holding them.

bash
psql "$POSTGRES_URL_NON_POOLING" -f supabase/staging/post-clone-scrub.sql
# The direct host resolves to IPv6 ONLY. From an IPv4-only network, use the
# SESSION pooler (port 5432; its username carries the branch project id):
psql "${POSTGRES_URL/6543/5432}" -f supabase/staging/post-clone-scrub.sql

What it removes, and why:

TargetReason
calendar_connections (all rows)Real Google OAuth refresh tokens. Staging reconnects via its own Google OAuth client (W5).
notification_push_subscriptions (all rows)Live Web Push endpoints and keys for real devices.
Webhook config in automation_actions.action_config and in automation_rules.workflow_data nodesUser-supplied URLs and headers. A webhook URL is itself a credential for Slack-style endpoints; headers can carry Authorization values. URLs are replaced with the https://scrubbed.invalid/ sentinel; headers and payload templates are removed (users authenticate to receivers by embedding API keys in the body, so the template is credential-bearing too; the executor falls back to its default payload). Workflow structure stays intact and editable.
automation_executions (all rows)Execution history echoes resolved webhook URLs into action_results and error_details.
automation_scheduled_jobs (all rows)Pending jobs snapshot their trigger context, and those snapshots carry meeting URLs (scheduler.ts writes meeting_url into context_snapshot; delay continuations copy execution context). They also reference production-side state whose executions are deleted above, so staging accumulates its own jobs instead.
App-issued bearer tokens: episode_guests.access_token, analytics_report_shares.token, team_invitations.token, podcast_imports.verification_token (rotated to fresh randoms); episode_guests.verification_code cleared; notification_email_tracking_tokens deletedProduction validates these by equality alone, so a value read out of staging opens the production surface (guest portal, public report link, invite accept). Rotation keeps staging links working with the new values.
auth.refresh_tokens, auth.sessions, auth.mfa_amr_claims, auth.one_time_tokens, auth.flow_state (+ saml_relay_states), auth.mfa_factors (+ challenges), auth.oauth_* (all rows); one-time token columns on auth.users blankedA cloned refresh token is accepted by equality at production's /token endpoint, so cloned sessions are live production credentials; in-progress PKCE flows carry auth_code/code_verifier/provider tokens, MFA factors carry TOTP shared secrets, and the OAuth-server tables carry client secrets and grants. Staging users sign in fresh. Kept: auth.identities (external providers disabled, so profile fields only) and audit_log_entries (log, not credentials).
booking_sessions.meeting_url, google_event_id, google_calendar_id (nulled, rows kept) plus a reset of calendar_event_status/_error/_creation_started_at to the clean none stateA Meet URL is a live production room joinable by anyone holding the link, and the guest context RPC returns it to cloned guest tokens; the event/calendar ids identify the real calendar event. The state reset keeps the sessions recoverable in staging — a created session with no identifiers would be rejected by claim_calendar_event_creation and never re-prompted by the recovery UI.

The script refuses to run against the wrong database using two checks derived from the connection and the server rather than anything the caller asserts: the connection identity must name the branch (either the direct host, or a *.pooler.supabase.com host with the branch-qualified username postgres.<project-id> on port 5432 — session mode; the 6543 transaction pooler is refused because it does not preserve the session state the script relies on), and the server must not be the production cluster, identified by the pair (pg_control_system() system identifier AND pg_control_checkpoint() timeline — a --with-data branch is a physical fork that inherits the sysid but advances the timeline, verified live: production is timeline 1, the branch fork timeline 2). A refusal is a loud no-op; nothing is deleted.

Deliberately left in place: test-mode Stripe ids, crm_* helpdesk mirrors, podcasts.apple_verify_token (published in the public feed by design), and auth.users password hashes (possessing a bcrypt hash does not grant production login, and the genuine accounts are deleted by the operator anyway).

Removing the genuine accounts (after the scrub)

The pre-launch clone carries the founders' genuine accounts and podcasts, removed by supabase/staging/delete-genuine-accounts.sql (same guard as the scrub; single transaction). The dashboard user-delete cannot do this — NO ACTION references into auth.users block it — so the script pre-nulls every referencing column while the users still exist (sections before their show-notes parent, whose trigger copies the old editor id upward) and deletes the targets' chat messages (valid_sender requires exactly one of user/guest), then deletes podcasts, orphaned billing accounts, and the users.

bash
psql "$STAGING_SESSION_URL" -f supabase/staging/delete-genuine-accounts.sql

Re-clone checklist (a small cutover, not a refresh)

Re-seeding staging means deleting and recreating the branch, and recreation issues a new project id. That invalidates every consumer of the old one:

  1. supabase branches delete staging, then re-create with --persistent --with-data.
  2. Update [remotes.staging].project_id in supabase/config.toml.
  3. Update the expected host constant in supabase/staging/post-clone-scrub.sql, then run the scrub.
  4. Recreate or re-point the staging Hyperdrive config (its origin names the old branch host).
  5. Rotate the branch URL/keys everywhere they were provisioned (staging workers and app, W5 onwards).
  6. Re-run db:verify --remote against the new branch.
  7. Re-run supabase/staging/delete-genuine-accounts.sql (the dashboard user-delete is blocked by NO ACTION references; the script handles ordering and triggers).
  8. Re-do the branch's dashboard auth config — none of it survives recreation: site URL + redirect allowlist, custom SMTP (Resend key + staging sender), session time-box / inactivity timeout, leaked-password protection, OTP expiry/length. The full as-built list lives in Staging Environment § One-time manual steps, item 4.

Internal documentation - Not for public distribution