CapyDB/ docs
GuidesImports & Migrations

Migrate from Supabase

Migrate a Supabase Postgres database to CapyDB: scan which Supabase services you use, import the data, and keep your RLS policies by restoring the dump.

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 capysquash engine (AST-level consolidation with catalog-proven equivalence, Supabase/Drizzle/Prisma aware); it needs the capysquash 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.

The baseline keeps your history's grants, ownership and default privileges (for example the anon, authenticated and service_role grants a Supabase history makes): it creates the roles the history creates when they are missing, and ends with the net privileges once every object exists. The catalog comparison covers owners, privileges and default privileges too, so any role your history grants to without creating it must exist where the baseline is validated and applied.

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-N-<region>.pooler.supabase.com:5432) - IPv4-reachable and fine for an import (it behaves like a session-mode connection). The host prefix (aws-0, aws-1, ...) differs per project, so copy it rather than guessing.
  • 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.

With a Supabase access token in SUPABASE_ACCESS_TOKEN, capydb import and capydb import preflight read the project's pooler host from the Supabase Management API and warn when your string uses the wrong aws-* prefix, the direct host, or port 6543, naming the session pooler to use.

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 --from supabase --source-url "postgres://postgres.<ref>:password@aws-1-eu-central-1.pooler.supabase.com:5432/postgres"

--from supabase moves a transaction-pooler URL (port 6543) to the session pooler on the same host and adds sslmode=require when missing.

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

Keeping your RLS policies: restore the dump instead

The managed import drops policies that call auth.uid() - they cannot work outside Supabase as written. To keep them, restore a dump with the converted policies in one step:

pg_dump -Fc -f supabase.dump "postgres://postgres.<ref>:password@aws-1-eu-central-1.pooler.supabase.com:5432/postgres"
capydb import --from-supabase-dump supabase.dump --dry-run
capydb import --from-supabase-dump supabase.dump

It applies, in one transaction, the auth.* compatibility shim, your schemas, the data, the constraints, and then FORCE ROW LEVEL SECURITY with the policies converted by capyrls. Foreign keys into auth.users, grants to the PostgREST roles and Realtime publications stay behind and are listed. The project must be empty and have the dump's extensions enabled. The policy report (in capyrls/) lists the session context your app must now set. The manual recipe is what this command automates.

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-N-<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.