Setting Up CI/CD for Supabase Schema Migrations
Henry Nguyen

Setting Up CI/CD for Supabase Schema Migrations
Most Supabase projects start with schema changes made directly through the dashboard's SQL editor. That's fine for a solo prototype. It stops being fine the moment there's more than one environment, more than one developer, or a deploy pipeline that needs to run reliably without someone remembering which SQL script to paste in manually. Here's the setup that fixes it.
Why dashboard-only changes break down
Making schema changes through the Supabase dashboard has a specific failure mode: there's no record of what changed, when, or whether staging matches production. A few months in, it's common to find staging and production have quietly diverged - a column added in one, an index added in the other, nobody remembers which is correct. Debugging a "works on staging, broken in production" issue that turns out to be a schema drift problem is a frustrating way to lose an afternoon.
The fix is treating schema changes as code: versioned, reviewed, and applied the same way in every environment through an automated pipeline.
The core tool: Supabase CLI migrations
The Supabase CLI generates timestamped SQL migration files that live in your repo:
supabase migration new add_orders_table
This creates supabase/migrations/<timestamp>_add_orders_table.sql, where you write the actual schema change:
create table public.orders (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) not null,
status text not null default 'pending',
total_cents integer not null,
created_at timestamptz default now()
);
alter table public.orders enable row level security;
create policy "Users can view their own orders"
on public.orders for select
using (auth.uid() = user_id);
Every migration file is a plain SQL script, checked into git, applied in timestamp order. That alone solves the "what changed and when" problem - git log on the migrations folder is your schema changelog.
Local development loop
Before anything touches CI, the local loop matters:
supabase start # spins up local Postgres + Supabase stack in Docker
supabase migration new my_change
# edit the generated SQL file
supabase db reset # wipes local DB, replays all migrations from scratch
db reset is the important habit here - it forces every migration to be replayable from zero, which catches ordering bugs and typos before they ever reach a shared environment. A migration that only works when applied on top of manually-tweaked local state is a migration that will fail in CI.
Wiring it into CI/CD
The pipeline shape that's held up well across projects: pull request → apply migrations against a staging project → merge to main → apply migrations against production, all through the same CLI command so there's no room for a manual step to diverge from what's in the repo.
A GitHub Actions example:
name: Deploy Supabase Migrations
on:
push:
branches: [main]
jobs:
migrate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Link project
run: supabase link --project-ref $SUPABASE_PROJECT_ID
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
- name: Push migrations
run: supabase db push
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
For pull requests, the same job runs against a separate staging project reference, so migrations get validated against a real (if disposable) database before anything reaches production.
The habit that prevents the worst outcomes: always write a rollback plan
supabase db push applies forward migrations - it does not automatically generate a way back. Before merging any migration that changes existing data (not just adding new tables), write the reverse operation as a matching down-migration or at minimum document it in the PR description. A migration that drops a column is a five-second operation to apply and, without a plan, a genuinely painful one to undo if it turns out something still depended on that column in production.
For anything touching existing production data - backfills, column type changes, dropping columns - run it against a copy of production data in staging first, not just an empty schema. An empty database will happily apply a migration that fails or behaves unexpectedly against real data volumes and real edge cases (nulls, unexpected values from years of accumulated rows).
What this setup actually buys you
- Staging and production schemas can never silently drift - both are built from the same migration history.
- Code review happens on schema changes the same way it happens on application code, catching RLS policy mistakes or missing indexes before they ship.
- Onboarding a new developer means
supabase db resetgives them the exact current schema, not a stale dump someone exported six months ago. - Rolling back a bad deploy includes the database, not just the application code.
None of this is exotic - it's the same discipline most teams already apply to application code, just extended to cover the schema. The only real cost is the discipline to never make an ad-hoc change through the dashboard once this is in place, because that's exactly the habit that reintroduces drift.
Related reading: I Migrated a 50k-User App from Firebase to Supabase in Two Weeks - Here's What I Learned · Optimizing Supabase Costs as Your App Scales
If you're still managing schema changes manually through the dashboard and want help setting up a proper migration pipeline, that's a quick, well-scoped piece of work I take on regularly - book a call.


