Jordi Enric

Software Engineer at Supabase logoSupabase

Back

January 27, 2026

Ship Postgres Migrations to Supabase Prod Without Pain

Supabase gives you:

  • Local Postgres (supabase start)

  • SQL migrations in supabase/migrations/

  • A CLI to apply them and wire into CI

Below are three deployment patterns. Pick based on team size and risk tolerance.


The Fundamentals

Local development

  • Local DB via supabase start

  • Generate migrations with supabase migration new or supabase db diff

  • Validate by nuking + recreating:

supabase db reset

If reset passes, migrations are valid from zero.

Source of truth

  • Schema lives in supabase/migrations/ in git

  • Avoid manual dashboard edits in remote envs

  • If someone edits remotely, use supabase db pull to bring it back into code


Pattern A: Local → Production (Solo Developer)

Simplest setup. Good when you’re the only person touching the DB.

Flow

  1. Edit schema locally

  2. Generate migration

  3. supabase db reset locally

  4. Commit to git

  5. Push to main

  6. CI applies migrations to prod project

CI Diagram

flowchart LR
  A[Push to main] --> B[CI]
  B --> C[Run tests on local DB]
  C --> D[Apply migrations to prod]
  D --> E[Done]

Sample GitHub Action (Pattern A)

name: Migrations - Solo Local to Prod

on:
  push:
    branches: [ main ]

jobs:
  migrate-prod:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: supabase/setup-cli@v1
        with:
          version: latest

      # Optional: sanity-check migrations on a local DB
      - name: Validate migrations locally
        run: |
          supabase db start
          supabase db reset

      - name: Push migrations to prod
        env:
          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
          SUPABASE_DB_PASSWORD:  ${{ secrets.PROD_DB_PASSWORD }}
          SUPABASE_PROJECT_ID:   ${{ secrets.PROD_PROJECT_ID }}
        run: supabase db push --non-interactive --linked

SUPABASE_ACCESS_TOKEN, SUPABASE_DB_PASSWORD, SUPABASE_PROJECT_ID are exactly what Supabase recommends for non-interactive CLI in CI.

Pros

  • Fast and simple

  • Good for solo side projects

  • Easy mental model

Cons

  • No shared staging env

  • Mistakes go straight to prod


Pattern B: Local → Remote Branch (Staging) → Prod Branch

(Supabase Branching, Pro tier)

Here you use Supabase Database Branching:

  • One Supabase project

  • Multiple DB branches: e.g. staging and production

  • Each branch has its own DB URL / credentials

Good for teams that want a proper staging env without juggling multiple projects.

Flow

  1. Work locally on a feature branch

  2. Generate migrations and test locally

  3. Push to Git develop → CI applies to staging branch

  4. After sign-off, merge to main → CI applies to production branch

CI Diagram

flowchart LR
  subgraph Staging
    A[Push to develop] --> B[CI]
    B --> C[Apply to Supabase DB branch: staging]
  end

  subgraph Production
    D[Merge to main] --> E[CI]
    E --> F[Apply to Supabase DB branch: production]
  end

Sample GitHub Action (Pattern B)

Assuming:

  • Git branch develop → Supabase DB branch staging

  • Git branch main → Supabase DB branch production

  • You store branch DB URLs as secrets

name: Migrations - Branching (Staging & Prod)

on:
  push:
    branches: [ develop, main ]

jobs:
  migrate:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Validate migrations locally
        run: |
          supabase db start
          supabase db reset

      - name: Select target DB URL
        id: target
        run: |
          if [[ "${GITHUB_REF_NAME}" == "develop" ]]; then
            echo "db_url=${{ secrets.SUPABASE_STAGING_DB_URL }}" >> "$GITHUB_OUTPUT"
          else
            echo "db_url=${{ secrets.SUPABASE_PROD_DB_URL }}" >> "$GITHUB_OUTPUT"
          fi

      - name: Push migrations to branch
        env:
          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
        run: |
          supabase db push \
            --db-url "${{ steps.target.outputs.db_url }}" \
            --non-interactive

Here we use --db-url to point the CLI directly at the branch DB connection string, which Supabase exposes per branch.

Pros

  • Proper isolated staging env

  • No extra projects

  • Teams can test against a realistic DB

Cons

  • Pro feature

  • You now manage multiple DB URLs

  • Still need discipline to avoid “hotfix in prod branch without migration”


Pattern C: Local → Free Staging Project → Prod Project

This one uses two remote projects:

  • Project 1: staging (free)

  • Project 2: production (free or paid)

Good for teams on free tier willing to manage two Supabase projects.

Flow

  1. Local changes

  2. Generate migrations

  3. Push to develop → CI applies to staging project

  4. Test & QA

  5. Merge to main → CI applies same migrations to prod project

CI Diagram

flowchart LR
  subgraph Staging project
    A[Push to develop] --> B[CI]
    B --> C[Apply migrations to free staging project]
  end

  subgraph Prod project
    D[Merge to main] --> E[CI]
    E --> F[Apply migrations to prod project]
  end

The Drift Problem

If people edit schemas directly in:

  • Staging project dashboard

  • Prod project dashboard

you will eventually have two different schemas and one sad developer.

Fix it by making code the source of truth:

  • Only change DB through migrations

  • supabase db diff to capture manual changes into migrations

  • If a project is totally cursed, recreate it from migrations

Sample GitHub Action (Pattern C)

name: Migrations - Dual Projects (Staging & Prod)

on:
  push:
    branches: [ develop, main ]

jobs:
  migrate:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Validate migrations locally
        run: |
          supabase db start
          supabase db reset

      - name: Set Supabase env for staging or prod
        id: target
        run: |
          if [[ "${GITHUB_REF_NAME}" == "develop" ]]; then
            echo "project_id=${{ secrets.STAGING_PROJECT_ID }}" >> "$GITHUB_OUTPUT"
            echo "db_password=${{ secrets.STAGING_DB_PASSWORD }}" >> "$GITHUB_OUTPUT"
          else
            echo "project_id=${{ secrets.PROD_PROJECT_ID }}" >> "$GITHUB_OUTPUT"
            echo "db_password=${{ secrets.PROD_DB_PASSWORD }}" >> "$GITHUB_OUTPUT"
          fi

      - name: Push migrations to remote
        env:
          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
          SUPABASE_PROJECT_ID:   ${{ steps.target.outputs.project_id }}
          SUPABASE_DB_PASSWORD:  ${{ steps.target.outputs.db_password }}
        run: supabase db push --non-interactive --linked

This follows Supabase’s recommended CI env var setup, just parametrized per branch.

Pros

  • Real staging env separate from prod

  • Works on free org with 2 free projects

  • Clear blast radius: staging vs prod

Cons

  • Schema drift if people click in dashboards

  • More secrets and config to manage

  • Slightly more complex CI


Quick Comparison

PatternEnvsSupabase featuresTeam sizeSafetyA Local→Prod1 prod projectBaseSoloLowB Local→Branch→Prod1 project, multiple DB branchesBranching (Pro)TeamMedium–HighC Local→Free→Prod2 projectsBaseTeamHigh (with discipline)


Declarative Schemas (Teaser)

There’s a whole other world where:

  • You keep a single declarative schema file

  • A tool computes diffs and generates migrations

  • CI checks remote DB drift against the desired schema

That’s a good topic for its own post. It plays nicely with all three patterns above, but changes how you author changes.

Back to all posts