Back to Blog
Development

Setting Up CI/CD for Supabase Schema Migrations

Henry Nguyen

Henry Nguyen

•4 min read
Setting Up CI/CD for Supabase Schema Migrations

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 reset gives 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.

Share this article