Three people share one Supabase project called myapp-dev. On Tuesday one of them
renames a column. The other two get column "full_name" does not exist in the
middle of unrelated work. Someone runs a destructive migration to unblock
themselves and wipes the fixtures everybody was relying on. By Friday the team has
a rule: "tell the channel before you touch dev".
That rule is the smell. A shared development database is a mutex with no lock, and it gets worse as the team grows. Supabase gives you two ways out, and they are not alternatives - they cover different parts of the workflow.
The wrong way: one hosted project everyone points at
.env.local (identical on three laptops)
NEXT_PUBLIC_SUPABASE_URL=https://abcdefgh.supabase.co
DATABASE_URL=postgresql://postgres.abcdefgh:...@aws-0-eu-west-1.pooler.supabase.com:6543/postgres
What breaks:
- Migrations are applied out of order. Whoever pushes first wins; the second person's migration assumes a schema that already moved.
- You cannot test a destructive change. Dropping a column to see what breaks breaks it for everyone.
- Seed data rots. After a month,
devholds a mixture of fixtures, half-finished test rows and one person's manual debugging. - Nothing is reproducible. A bug that only appears on one laptop is untraceable, because the database is not part of the repository.
The right way, part one: local for the inner loop
The Supabase CLI runs the whole stack - Postgres, PostgREST, GoTrue, Realtime, Storage, Studio - in Docker on your machine.
bun run db:start # supabase start
bun run db:reset # replay supabase/migrations, seed, then db:migrate
bun run db:types # regenerate src/db/types.generated.ts
.env.local points at the containers:
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=<printed by supabase start>
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
The local anon and service-role keys are fixed demo JWTs signed with a public
secret. They are the same on every machine, they are safe to commit to
.env.example, and they are worthless outside localhost.
What you get is a database that is part of the repo. bun run db:reset takes a
few seconds and returns you to a known state: every migration replayed from empty,
then every supabase/seed/*.sql, then your ORM's own migrations. That single
command is also your migration test - a migration that works as a one-off
statement but fails on a clean replay is broken, and you find out in the loop
rather than during a deploy.
Costs to be honest about: Docker has to be running, the images are roughly a
gigabyte, and the first supabase start after a version bump is slow. Pin
major_version in supabase/config.toml to whatever the hosted project runs -
Project Settings, Infrastructure - or you will develop against Postgres 15 and
deploy to 17. bun run verify compares the pin against the version it actually
connected to and fails when they disagree, so this is one mistake you do not have
to remember.
The right way, part two: branches for preview deploys
Local stops working the moment something outside your laptop needs the database. A
Vercel preview deployment cannot reach 127.0.0.1:54321. Neither can a designer
clicking your PR link, nor a Playwright run in a container, nor a webhook from
Stripe.
That is what Supabase branching is for. A branch is a real, separate Supabase project - its own URL, its own keys, its own Postgres - created from your repository's migrations.
bun run db:link --project-ref <production-ref>
bunx supabase branches create preview-checkout --persistent
bunx supabase branches list
bunx supabase branches get preview-checkout # prints the branch URL and keys
Wire those values into the preview environment of your host. On Vercel, set them as Preview-scoped environment variables:
bunx vercel env add NEXT_PUBLIC_SUPABASE_URL preview
bunx vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY preview
bunx vercel env add DATABASE_URL preview
Branch creation runs supabase/migrations and supabase/seed/*.sql against a fresh
database, so a branch is reproducible in the same way a local reset is - with one
gap. Supabase knows nothing about your ORM's migration history, so a branch comes
up with the Supabase half of the schema and none of the ORM half; run the ORM's
deploy command against the branch's connection string before you point a preview
at it. Two other things to know before you rely on branches: they are a paid
feature and are billed like small projects, and creating one takes minutes rather
than seconds because a real Postgres instance is being provisioned. Delete branches you are not using -
bunx supabase branches delete <name> - or you will pay for a dozen abandoned
preview databases.
What about staging?
A long-lived staging project sits between the two. It is worth having when you need
data that survives - a support team clicking through, a load test, an integration
partner with a fixed callback URL. Treat it exactly like production: migrations
arrive through supabase db push, nobody edits it in the dashboard, and it never
holds a copy of real user data unless it has been anonymised.
The decision table
| Situation | Use |
|---|---|
| Writing a feature, iterating on schema | Local CLI |
| Testing a destructive migration | Local CLI, then db:reset |
| Vercel preview deployment on a PR | Branch, Preview-scoped env vars |
| External webhook needs to reach the app | Branch or staging |
| Demo for a stakeholder | Staging |
| Anything a customer touches | Production only |
The rule that actually prevents the Tuesday incident
Nobody points a laptop at a shared hosted project. If a person needs a database, it
is either in Docker on their machine or it is a branch that belongs to their pull
request. supabase/migrations and supabase/seed/ are the only mechanism by
which schema and fixtures travel between them, which means the database is
reviewable in a diff like everything else.