CapyDB/ docs
GuidesImports & Migrations

Migrate from Supabase

Move the Postgres database out of a Supabase project into CapyDB.

Scan first: which Supabase exit are you making?

capydb migrate scan                                  # repo only; read-only, no login needed
capydb migrate scan --source-url "postgres://..."    # + read-only probes of the live database

Supabase migrations differ wildly by how much of the platform you use, and the scan grades yours by counting call sites per kind - data (supabase.from()), auth, storage, realtime:

  • Auth on Clerk (or similar), data via a direct Postgres driver - the DB move is an env swap plus storage/realtime replacement. Small.
  • Auth on Clerk, data via supabase.from() - rewrite the data layer to a Postgres client first, against your current Supabase DB (it's plain Postgres), then import. The rewrite size is the call-site count the scan prints.
  • Supabase Auth is your identity system - a DB-only move buys nothing and breaks login. Migrate auth first; see Auth systems and your migration.
  • BetterAuth / Auth.js / Lucia - their tables live in your schema and ride the dump. Nothing auth-related to do.

The preflight (below) reports the database-side version of the same facts: foreign keys into auth.users, tables with auth.uid() RLS policies, and schemas beyond public.

What --source-url adds

With a direct or session-pooler URL (below), the scan runs read-only probes of the live database and cross-checks them against your code - migration folders and repo greps drift from what is actually deployed:

  • RLS reality: the live policy count, classified by how policies resolve the caller - direct auth.* references vs your own helper functions whose bodies read auth.jwt() - plus a recommended path: keep the policies via capydb migrate rls, or rewrite them as app-layer guards (which one, and why).
  • auth.users count and last sign-in - the "do you actually have users yet" gate. If auth.users is still empty, migrate before onboarding users: a zero-user window turns the hardest exit (Supabase Auth as your identity system) into picking a provider and cutting over, with nobody to re-authenticate.
  • Extensions: installed but with zero dependent objects (the scan tells you to filter them from the dump - nothing uses them) vs not on the allowlist with dependents (needs a decision before the window, not during).
  • Storage: buckets with object counts and sizes, plus absolute provider storage URLs persisted in your data columns - those turn the storage exit into a data backfill; see Replacing Supabase services.
  • Realtime: the supabase_realtime publication vs the .channel() call sites your code actually has - the publication is usually a superset of what anything listens to.
  • RPC cross-check: .rpc() names in code vs CREATE FUNCTION statements in local SQL vs the live database. A function that exists only live (SQL-editor, never committed) must be recovered before cutover or the call site breaks silently.
  • Populated migration-bookkeeping tables from other tools - the signature of another data import already in flight. See the freeze rule under Import.

Long migration histories: consolidate before you move

A repo carrying dozens or hundreds of migration files has usually drifted from what is actually deployed (the scan warns at 50+, and the live probes show the drift directly). Rather than replaying a long, partly-stale history onto the new database, consolidate it into a clean baseline first:

capydb migrate squash                      # read-only analysis of the consolidation potential
capydb migrate squash --workflow safe      # conservative consolidation, validated in Docker
capydb migrate squash --workflow safe \
  --validation capydb --project my-project # validate in one isolated CapyDB preview cell

The command wraps the standalone open-source pgsquash engine (AST-level consolidation with catalog-proven equivalence, Supabase/Drizzle/Prisma aware); it needs the pgsquash binary on PATH and tells you how to install it if missing. Analysis changes nothing. Managed validation generates the candidate locally, builds the original and candidate schemas sequentially in one empty preview cell, deletes the preview, and writes the requested output only after the catalogs match. The baseline is a repository artifact for new databases or an explicit rebaseline - do not execute it over the already-imported live database.

Get the source connection string

In the Supabase dashboard: Project → Connect (top bar). You will see three flavors - pick carefully:

  • Direct connection (db.<ref>.supabase.co:5432) - best for imports, but it is IPv6-only on most projects unless the IPv4 add-on is enabled.
  • Session pooler (aws-0-<region>.pooler.supabase.com:5432) - IPv4-reachable and fine for an import (it behaves like a session-mode connection).
  • Transaction pooler (port 6543) - rejected outright by preflight and import (a logical copy cannot run through transaction pooling).

Practical rule: use the session pooler URL unless you know your project has IPv4 enabled on the direct host. The username on pooler URLs includes the project ref (postgres.<ref>), which is expected.

Supabase-specific caveats

  • You are migrating the database, not the platform. Supabase Auth, Storage, Edge Functions, and Realtime do not come along. When the importer sees a Supabase source URL it automatically excludes the platform-managed schemas (auth, storage, realtime, extensions, graphql, pgsodium, vault, supabase_functions, and friends) - only your application schemas (public and any you created) are copied. If you use Clerk, CapyDB's Clerk auth sync covers the user-table-in-your-database pattern.
  • RLS policies are dropped during the import - but you don't have to lose them. They reference auth.uid() and the anon/authenticated roles, none of which exist in plain Postgres, so a single one would abort the whole restore. If RLS was doing real authorization work, run capydb migrate rls first: it converts the policies to portable, vanilla Postgres (transaction-local session settings instead of PostgREST-injected JWTs) so they keep enforcing after cutover. If RLS was only PostgREST plumbing you never relied on, dropping it changes nothing at runtime.
  • Extension schema layout is mirrored. Supabase installs extensions into an extensions schema, so your column defaults look like extensions.uuid_generate_v4(). The importer recreates that layout on the CapyDB side before restoring, so those defaults keep resolving.
  • Extensions: the CapyDB allowlist covers the common Supabase set - pgcrypto, uuid-ossp, pg_trgm, citext, hstore, pg_stat_statements, vector (pgvector), postgis, unaccent, ltree, fuzzystrmatch, btree_gin, btree_gist. Supabase-only machinery (pg_graphql, pgsodium, supabase_vault) lives in the excluded schemas and never reaches the preflight.
  • Foreign keys into auth.users are the one thing that can still fail the restore: the excluded auth schema means such a constraint has no target. Drop those FKs on the source (or re-point them at your own users table) before importing.

Preflight

capydb import preflight --source-url "postgres://postgres.<ref>:password@aws-0-eu-central-1.pooler.supabase.com:5432/postgres"

Expect the extension check to be the interesting one. Fix failures before the window, not during.

Import

Pause writers - all of them. That includes any other data movement in flight: a CMS import filling tables row by row, a sync job, another migration tool's backfill (the scan's populated-bookkeeping-tables finding is exactly this signal). Two copies converging on one cutover means the import snapshot is stale the moment it lands - freeze everything else, import, verify, then let the other movement resume against CapyDB. Then:

capydb import --source-url "postgres://..." --recreate --wait

After the import

  1. Verify row counts with SELECT count(*) per table, and spot-check sequences on hot tables.
  2. \dx - verify the extension set; drop leftover Supabase-specific schemas you decided not to keep.
  3. ANALYZE;
  4. Cut over DATABASE_URL (pooled :6432 for app traffic, direct :5432 for migrations) on every deployable that uses this database, and deploy them together - a half-swapped pair of services silently splits writes across two databases.
  5. Confirm the exit before you pause the Supabase project:
capydb migrate verify --source-url "postgres://postgres.<ref>:...@aws-0-<region>.pooler.supabase.com:5432/postgres"

Supabase's own platform connections (PostgREST, exporters, admin roles) are filtered out; anything listed is one of your deployables still pointed at the old database. Keep the Supabase project paused - not deleted - for a rollback window.