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            # read-only; no login needed

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.

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. They reference auth.uid() and the anon/authenticated roles, none of which exist in plain Postgres - a single one would abort the whole restore. Your app connects as the database owner (which bypasses RLS), so this changes nothing at runtime; if RLS was doing real authorization work via PostgREST, that logic must move into your application layer before cutover.
  • 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, 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.