DEV Community

Mark F A
Mark F A

Posted on

Your Supabase Migrations Should Survive Any Postgres. Here's How I Test That.

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 auth shim, 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
$$;
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!,
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)