Restore a Supabase dump by hand
The exact order - and the six traps - for restoring a Supabase pg_dump into a CapyDB cell alongside a converted RLS bundle.
capydb import runs the managed path and is what most migrations should use. To keep your RLS
policies, capydb import --from-supabase-dump <file> runs the recipe on this page for you (see
Migrate from Supabase).
Reach for this page when you need to control the order yourself - for example to apply migrations
newer than the dump between the steps.
A Supabase pg_dump is not portable as-is. It references extensions.geography, auth.role(),
publications for supabase_realtime, ACLs for roles that do not exist outside Supabase, and policies
on schemas you do not own. Restoring it into plain Postgres takes a filtered table of contents and a
specific order.
Before you start
Two things that are not obvious and cost hours if you get them wrong.
Use a pg_dump that matches the source major. A newer pg_dump can emit syntax an older server
rejects. Check the source with select version() and pin the client to it:
docker run --rm postgres:17 pg_dump --versionThe direct host may be unreachable. On a modern Supabase project db.<ref>.supabase.co resolves
to an IPv6 address only. Without IPv6 egress (most CI runners, many laptops) you cannot reach it
at all. Use the session-mode pooler on port 5432 - it is a full session and pg_dump works
through it. Do not use port 6543: that is transaction mode and it breaks pg_dump.
Read the pooler host from the management API rather than guessing - the aws-0 / aws-1 prefix
varies per project:
curl -s -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
"https://api.supabase.com/v1/projects/$PROJECT_REF/config/database/pooler"Take the dump
Custom format, so the table of contents can be filtered later. Include every schema your application
owns - public is the natural filter and it is usually wrong, because app schemas hide behind it.
docker run --rm -e PGPASSWORD -v "$PWD:/out" postgres:17 \
pg_dump -h aws-1-<region>.pooler.supabase.com -p 5432 -U postgres.<project-ref> \
-d postgres --format=custom --no-owner --no-privileges \
--schema=public --schema=<your-other-schemas> \
-f /out/source.dumpVerify it is restorable before trusting it - the file existing is not the same as the archive being intact:
docker run --rm -v "$PWD:/out" postgres:17 pg_restore --list /out/source.dump | wc -lConvert the policies
capydb migrate rls --source-url "$SOURCE_URL" \
--mode supabase-compat --role-model single \
--out ./rls-bundle--role-model single is what applies FORCE ROW LEVEL SECURITY to every table. It is not optional:
your application connects as the database owner, and an owner bypasses row security unless the
table is FORCEd. Without it your policies are decorative and nothing tells you so. The
alternative is --role-model split, where request traffic runs as the project's runtime role
app_user instead of the owner - it needs that role enabled first; see
role models.
The bundle includes a claims-based service escape: a transaction whose verified claims carry
"role": "service_role" skips the row filters, which is where your service-key call sites go. Set
that role only from server code that used the service key. If nothing in your app used the service
key, add --no-service-escape: the report then lists a policy that applied only to service_role
as access that is now denied.
Restore, in this order
The order is load-bearing. Each step exists because the one before it is a prerequisite.
- The compat shim (
capyrls_01_prelude.sql) - creates theauthschema. SQL-language function bodies referencingauth.*are validated atCREATE, so this must exist before any schema restores. - Pre-data, with
POLICY,ACLandPUBLICATIONentries filtered out. - Data.
- Post-data - indexes, constraints, triggers.
capyrls_02_force_rls.sql, thencapyrls_03_policies.sql- they need the tables to exist.
Filtering the table of contents:
pg_restore --list source.dump > full.list
grep -vE '^;|^$' full.list \
| grep -vE ' (POLICY|ACL|PUBLICATION|EVENT TRIGGER|SUBSCRIPTION|SCHEMA) ' \
> keep.list
for section in pre-data data post-data; do
pg_restore --section=$section -L keep.list --no-owner --no-privileges \
-f $section.sql source.dump
doneThen apply the five steps with psql -v ON_ERROR_STOP=1.
The six traps
--no-privilegesdoes not drop policies. It drops grants. Policies are separate TOC entries and must be filtered explicitly, or the dump'sCREATE POLICY ... TO authenticatedfails against roles that do not exist. It also drops schemaUSAGEgrants, which you may need to re-add.publicis not the only schema you own. Any app schema outsidepublicis silently missing otherwise, and you will not notice until a query fails. List them explicitly at dump time.- Extension schema qualification. Supabase installs extensions into a schema named
extensions; if you enable them on the cell withcapydb extensions enablethey land inpublic. Everyextensions.reference in the dump then fails. Either create the extensions in a matching schema before restoring, or rewriteextensions.topublic.in the emitted SQL. - Identity sequences cannot be dropped on their own. When wiping a cell between rehearsals, drop
tables before sequences and materialise the object names first - a lazily evaluated
regclasscast over an already-dropped OID degrades to a bare number and theDROPbecomes a syntax error. - Extensions that are installed but unreferenced.
cube,earthdistance,pg_netand the dashboard-onlyhypopg/index_advisorare frequently present with zero call sites. Filter them from the dump rather than trying to install them.capydb migrate scan --source-urlreports dependent-object counts so you can tell "load-bearing" from "installed once". - Realtime publications travel in the dump. The
supabase_realtimepublication entries and any trigger callingrealtime.send()reference a schema that will not exist. Drop them, and re-home the fan-out into your application after the write commits.
Verify
Count rows on both sides. With FORCE ROW LEVEL SECURITY applied, the owner is subject to policies
too, so a naive count(*) under-reports - lift FORCE inside a transaction and roll it back:
begin;
alter table public.<table> no force row level security;
select count(*) from public.<table>;
rollback;Then run the check that actually matters: a two-user denial test. Set claims for user A, read a row belonging to user B, and assert you get nothing. Run it against a table that was not already FORCEd on the source. Reading the policy list is not a substitute - the failure mode here is silent, and this is the only thing that catches it.