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 databaseSupabase 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 readauth.jwt()- plus a recommended path: keep the policies viacapydb migrate rls, or rewrite them as app-layer guards (which one, and why). auth.userscount and last sign-in - the "do you actually have users yet" gate. Ifauth.usersis 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_realtimepublication 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 vsCREATE FUNCTIONstatements 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 cellThe 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 (publicand 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 theanon/authenticatedroles, none of which exist in plain Postgres, so a single one would abort the whole restore. If RLS was doing real authorization work, runcapydb migrate rlsfirst: 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
extensionsschema, so your column defaults look likeextensions.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.usersare the one thing that can still fail the restore: the excludedauthschema 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 --waitAfter the import
- Verify row counts with
SELECT count(*)per table, and spot-check sequences on hot tables. \dx- verify the extension set; drop leftover Supabase-specific schemas you decided not to keep.ANALYZE;- Cut over
DATABASE_URL(pooled:6432for app traffic, direct:5432for migrations) on every deployable that uses this database, and deploy them together - a half-swapped pair of services silently splits writes across two databases. - 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.