TL;DR
- The only part of your local Supabase setup that should travel to production is
supabase/migrations/*.sql. Everything else is disposable. - If a migration only works because you clicked something in a dashboard once, it's not a migration. It's a memory.
- I replay every migration against a bare Postgres 17 in CI, with a tiny
authshim, on every PR. It catches ordering bugs, missing extensions, and edited history before they reach a real database. - Once migrations are the contract, your local runtime becomes swappable. That's the real payoff.
Most "Supabase local dev" posts are about the runtime: which containers, how much RAM, how fast it boots. This one is about the thing that outlives every runtime you'll ever use: the folder of SQL files.
The contract is the folder
Here's the only layout that matters:
supabase/
├── migrations/
│ ├── 20260901120000_create_projects.sql
│ ├── 20260905093000_projects_rls.sql
│ └── 20260912174500_add_project_members.sql
└── seed.sql # local-only data, never runs in prod
Timestamps order the history. Each file runs once, in order, and gets recorded in a history table so it never runs again. That's the whole model, and it's the same model whether the files land on a laptop, a CI box, or hosted Supabase.
You don't need any tooling to create one:
touch "supabase/migrations/$(date -u +%Y%m%d%H%M%S)_add_project_members.sql"
Five rules for migrations that travel
1. Nothing exists unless a migration created it
The classic drift: a column added through a dashboard, a policy tweaked in the UI, a function pasted into a SQL editor. It works on your database. A teammate clones the repo and it doesn't exist.
If it isn't in supabase/migrations, assume it doesn't exist.
2. Declare your extensions
create extension if not exists "pgcrypto" with schema extensions;
Hosted Supabase ships a lot of extensions pre-enabled. A fresh Postgres doesn't. Say what you depend on, in the migration that first needs it.
3. Tables and their RLS ship together
create table public.projects (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users (id) on delete cascade,
name text not null,
created_at timestamptz not null default now()
);
alter table public.projects enable row level security;
create policy "owners read own projects"
on public.projects for select
to authenticated
using ((select auth.uid()) = owner_id);
A table that exists for even one migration without RLS is a table that someone, someday, will query in that window. Same file, every time.
4. Never edit a migration that's already been applied
Applied history is immutable. Want to change it? Write a new migration. Editing an old file means your laptop and production now disagree about what "migration 3" did, and nothing will tell you.
A cheap guard for PRs:
#!/usr/bin/env bash
# scripts/no-edited-migrations.sh
set -euo pipefail
changed=$(git diff --name-only --diff-filter=MR origin/main -- supabase/migrations || true)
if [ -n "$changed" ]; then
echo "Applied migrations were modified:"
echo "$changed"
echo "Write a new migration instead."
exit 1
fi
5. Schema in migrations, data in seed
Test users, demo rows, and fixtures go in seed.sql. Mixing them into migrations means production eventually gets alice@example.com.
The replay check: bare Postgres 17 + a tiny auth shim
Here's the part I actually rely on. On every PR, CI spins up a vanilla Postgres 17 and replays every migration in order. If a file depends on something no migration created, it fails loudly.
Supabase migrations reference auth.users, auth.uid(), and the anon / authenticated roles, which a bare Postgres doesn't have. So the check loads a minimal shim first:
-- scripts/auth-shim.sql
-- NOT Supabase Auth. Just enough surface for migrations to apply.
create schema if not exists auth;
create schema if not exists extensions;
do $$
begin
if not exists (select from pg_roles where rolname = 'anon') then create role anon nologin; end if;
if not exists (select from pg_roles where rolname = 'authenticated') then create role authenticated nologin; end if;
if not exists (select from pg_roles where rolname = 'service_role') then create role service_role nologin bypassrls; end if;
end $$;
create table if not exists auth.users (
id uuid primary key default gen_random_uuid(),
email text,
raw_user_meta_data jsonb default '{}'::jsonb
);
create or replace function auth.uid() returns uuid
language sql stable as $$
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
$$;
And the replay script:
#!/usr/bin/env bash
# scripts/replay-migrations.sh
set -euo pipefail
psql -v ON_ERROR_STOP=1 -q -f scripts/auth-shim.sql
for f in $(ls supabase/migrations/*.sql | sort); do
echo "→ $f"
psql -v ON_ERROR_STOP=1 -q --single-transaction -f "$f"
done
echo "✅ All migrations replayed cleanly on a fresh Postgres"
Wire it into GitHub Actions:
name: migrations
on: [pull_request]
jobs:
replay:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-retries 10
env:
PGHOST: localhost
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: postgres
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: bash scripts/no-edited-migrations.sh
- run: bash scripts/replay-migrations.sh
What this catches:
- A migration that references a table created in a later file (timestamp mistakes happen)
- A missing
create extension - Objects that only ever existed in someone's dashboard
- Edited history
- Plain syntax errors, before a human reviews the PR
What it doesn't catch: real auth behavior, JWT claims, or whether your policies return the right rows. The shim is scaffolding, not Supabase. Test policy behavior against a runtime that actually implements the API.
Why this makes your local runtime swappable
Once the SQL folder is the contract, the local runtime is just "something that applies these files to real Postgres and serves the Supabase APIs on top." You can pick whichever one fits your machine today, and the choice stops being a commitment.
For my own laptop I've been using tinbase, an open-source, Supabase-compatible runtime on real Postgres 17 that runs as a single process without Docker. It reads the same supabase/migrations/*.sql files the Supabase CLI does and tracks them in the same way, so the folder above works unchanged:
npx tinbase start
And app code doesn't change either. It's the regular client pointed at the local URL:
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
'http://127.0.0.1:54321',
process.env.SUPABASE_ANON_KEY!,
);
Worth being upfront: it's alpha software built for local dev and prototypes, not production. Which is exactly the point of this workflow. The runtime is disposable. The migrations aren't.
When it's time to ship, the same files go to hosted Supabase with the CLI:
supabase link --project-ref <your-project-ref>
supabase db push
No rewrite, no export, no "which version of the schema is real."
The mental model
- Migrations are source code. Reviewed, immutable once applied, replayed in CI.
- Seed data is a local convenience.
- The local runtime is a tool you can swap on a Tuesday afternoon without anyone noticing.
If swapping your local runtime would break something, that's not a runtime problem. It's a migration that isn't telling the whole truth.
What's the worst schema drift bug you've hit between local and production? Dashboard edits, edited migrations, something weirder? Drop it in the comments. 👇
Top comments (0)