Database Schema Migrations for Self-Hosted Supabase: A Complete Guide

Learn how to manage database schema migrations in self-hosted Supabase with the CLI, avoid common pitfalls, and establish a reliable workflow.

Cover Image for Database Schema Migrations for Self-Hosted Supabase: A Complete Guide

Managing database schema migrations is one of the most misunderstood aspects of running self-hosted Supabase. Unlike Supabase Cloud, where migrations are handled through the dashboard and automatically tracked, self-hosted deployments require you to understand the underlying mechanics. Get it wrong, and you'll face sync errors, lost changes, or worse—production outages.

This guide covers everything you need to know about schema migrations for self-hosted Supabase: the tools, the workflow, and the pitfalls that trip up even experienced developers.

Why Migrations Matter for Self-Hosted Supabase

Database migrations are SQL statements that create, update, or delete your database schemas. They serve as a version control system for your database structure, letting you track changes over time and apply them consistently across environments.

For self-hosted Supabase, migrations become critical for several reasons:

Reproducibility: Without migrations, your production database schema exists only in production. If you need to spin up a staging environment or recover from a disaster, you're starting from scratch or relying on incomplete backups.

Team collaboration: When multiple developers work on the same project, migrations ensure everyone applies schema changes in the correct order. No more "it works on my machine" database issues.

Auditability: Migrations create a clear history of what changed, when, and (if you write good commit messages) why. This matters for compliance and debugging.

The challenge? Self-hosted Supabase doesn't automatically run migrations on existing databases when you update your Docker images. This catches many teams off guard.

The Core Problem: Migrations Don't Auto-Run

Here's the scenario that frustrates self-hosted users: You update your Supabase Docker images to get new features. You deploy. And nothing changes in your database.

Why? The official PostgreSQL Docker image only runs initialization scripts when there's no existing data directory. Once your database has data, subsequent container restarts skip all those scripts entirely. You'll see this message in your logs:

PostgreSQL Database directory appears to contain a database; Skipping initialization

This is by design—you don't want your database reinitialized every time you restart a container. But it means schema migrations embedded in newer Supabase versions won't apply automatically.

The solution is to manage migrations explicitly using the Supabase CLI.

Setting Up the Migration Workflow

Prerequisites

You'll need the Supabase CLI installed. If you haven't already set up your self-hosted instance, check out our deployment guide first.

# Install the Supabase CLI
npm install -g supabase

# Initialize in your project directory
supabase init

This creates a supabase/ directory with a migrations/ folder where your migration files will live.

Connecting to Your Self-Hosted Database

Unlike Supabase Cloud projects, self-hosted databases require explicit connection parameters. Use the --db-url flag to connect:

# Format: postgresql://[user]:[password]@[host]:[port]/[database]
supabase db push --db-url "postgresql://postgres:your-password@your-server:5432/postgres"

The first time you run a migration command, Supabase creates a supabase_migrations.schema_migrations table to track which migrations have been applied.

Creating Your First Migration

The golden rule: never change the remote database directly. All schema changes should flow through migration files.

Create a new migration:

supabase migration new add_user_profiles

This creates a timestamped file like 20260426120000_add_user_profiles.sql in your supabase/migrations/ directory. Add your SQL:

-- supabase/migrations/20260426120000_add_user_profiles.sql

create table if not exists public.user_profiles (
  id uuid references auth.users on delete cascade primary key,
  display_name text,
  avatar_url text,
  created_at timestamp with time zone default now(),
  updated_at timestamp with time zone default now()
);

-- Enable RLS
alter table public.user_profiles enable row level security;

-- Create policies
create policy "Users can view their own profile"
  on public.user_profiles for select
  using (auth.uid() = id);

create policy "Users can update their own profile"
  on public.user_profiles for update
  using (auth.uid() = id);

For more on RLS policies, see our Row Level Security guide.

Pushing Migrations to Production

With your migration file ready, push it to your self-hosted database:

supabase db push --db-url "postgresql://postgres:your-password@your-server:5432/postgres"

This command:

  1. Checks which migrations have already been applied
  2. Runs only the new migrations in order
  3. Updates the migration history table

Unlike db reset (which drops everything and starts fresh), db push is safe for production—it only applies what's missing.

Pulling Schema Changes

Sometimes you need to capture existing schema from a database. Maybe you made changes through the Supabase Studio dashboard (we've all done it), or you're onboarding an existing project.

supabase db pull --db-url "postgresql://postgres:your-password@your-server:5432/postgres"

This creates a new migration file containing the current database schema. But here's the catch: any changes you made directly to the database now need to be represented in your migrations folder. Otherwise, future db push operations will fail with sync errors.

Diffing for Incremental Changes

The db diff command is your friend for capturing incremental changes. It compares your target database against a shadow database and generates the SQL needed to sync them:

supabase db diff --db-url "postgresql://postgres:your-password@your-server:5432/postgres" -f new_changes

This uses migra under the hood to detect schema differences and output them as a migration file.

Handling the Declarative Schema Approach

For complex objects like views and functions, traditional migrations become verbose. Every change requires rewriting the entire object. Supabase supports a declarative approach where you define these objects in place:

-- supabase/schemas/public/views/active_users.sql

create or replace view public.active_users as
select
  id,
  email,
  last_sign_in_at
from auth.users
where last_sign_in_at > now() - interval '30 days';

Changes to this file are detected and applied automatically. This is particularly useful for:

  • Views
  • Functions and procedures
  • Triggers
  • Custom types

Common Pitfalls and How to Avoid Them

Pitfall 1: Direct Database Edits

The most common mistake. You use the Supabase Studio SQL editor to "quickly" add a column. Now your migrations are out of sync, and the next db push fails.

Solution: Always create a migration file, even for small changes. The few extra seconds save hours of debugging.

Pitfall 2: Missing Supabase System Migrations

Your app's supabase/migrations aren't the only migrations that matter. The Supabase PostgreSQL Docker image contains embedded migrations that set up auth, storage, and realtime schemas.

When you upgrade Supabase versions, these internal migrations don't auto-apply. You need to run them manually or understand that your self-hosted instance may be missing features.

Solution: After major version upgrades, compare your schema against a fresh Supabase installation to identify missing system tables or functions. Our version migration guide covers this in detail.

Pitfall 3: Environment Drift

Your local development database drifts from staging which drifts from production. Each environment has slightly different schemas.

Solution: Use the same migration files everywhere. Run db push as part of your CI/CD pipeline. Never apply migrations manually to just one environment.

Pitfall 4: Destructive Migrations Without Rollback Plans

-- Don't do this without a plan
drop table users;

Solution: For destructive changes, create a backup first. Consider using our backup and restore procedures to test migrations against a copy of production data.

Integrating Migrations into CI/CD

For production deployments, migrations should be automated. Here's a basic GitHub Actions workflow:

name: Deploy Migrations

on:
  push:
    branches: [main]
    paths:
      - 'supabase/migrations/**'

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Supabase CLI
        uses: supabase/setup-cli@v1
        with:
          version: latest
      
      - name: Push migrations
        run: |
          supabase db push --db-url "${{ secrets.DATABASE_URL }}"

For a complete CI/CD setup, see our CI/CD pipelines guide.

Alternative Tools

While the Supabase CLI works well, some teams prefer dedicated migration tools:

  • Atlas: A modern schema management tool that integrates with Supabase
  • Flyway: Battle-tested migration runner used by enterprises
  • sqitch: Change management for databases with dependency tracking

These tools can replace the CLI's migration functionality while still working with your self-hosted Supabase instance.

Managing Migrations with Supascale

If migration management sounds like overhead you'd rather avoid, Supascale can help. While we don't replace the Supabase CLI for schema migrations (those are developer workflow tools), we handle the operational challenges that make self-hosting complex:

  • Automated backups before risky migrations
  • One-click restore if a migration goes wrong
  • Environment management to keep multiple projects in sync
  • Monitoring to catch schema-related performance issues

For teams running multiple self-hosted Supabase projects, the operational burden adds up quickly. Check our pricing page to see how Supascale simplifies management at $39.99 for unlimited projects.

Conclusion

Database schema migrations for self-hosted Supabase require more hands-on management than the cloud version. The key takeaways:

  1. Use migration files for all schema changes—no exceptions
  2. The Supabase CLI's db push command is your primary tool for applying migrations to self-hosted databases
  3. Migrations don't auto-run when you update Docker images—you must push them explicitly
  4. Integrate migrations into CI/CD to prevent environment drift
  5. Always have a backup before running destructive migrations

With a solid migration workflow, your self-hosted Supabase database becomes as manageable as any cloud-hosted solution—with the added benefits of full control and predictable costs.


Further Reading