Back to Blog
Development

Setting Up CI/CD for Supabase Schema Migrations

Codoric Team

Codoric Team

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.

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