The answer. Dump-and-restore is the easy half of leaving self-hosted Supabase. The half that bites is everything the database doesn't carry: API keys, the JWKS your API cached, storage buckets, and every client build with the old URL baked in. Split the cutover into two checklists. A data-parity check you run once: per-table row counts plus the auth.users / auth.identities set, old versus new, identical or you stop. Then a client checklist you run per surface (API, dashboard, browser extension) until each one proves it talks only to the new project.
The pattern.
-- Run on BOTH databases, diff the output. Exact counts, not estimates.
select n.nspname || '.' || c.relname as tbl,
(xpath('/row/c/text()', query_to_xml(format('select count(*) as c from %I.%I',
n.nspname, c.relname), false, true, '')))[1]::text::int as rows
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname in ('public', 'auth')
order by 1;
# Per client surface, only after the data check passes:
wrangler secret put SUPABASE_URL --env production # 1. new URL + anon + service_role
wrangler kv key delete "jwks:supabase:v1" --binding CACHE --remote # 2. flush cached OLD signing keys
pnpm build && grep -rl "old-project-host" dist/ # 3. rebuild static clients; expect no hits
# 4. prove it: mint a real session on the new project, call an authed route, expect 200
The receipt. In our product (HarperFlow), the self-hosted → hosted Supabase cutover completed on 2026-07-10. The database leg was checked at 45 tables / 685 rows / 5 users (with their identities) identical on both sides before any client moved. The client leg then found what the dump didn't carry: the hosted project had zero storage buckets (storage-buckets-do-not-migrate-with-the-database); the API Worker's KV-cached JWKS still held the old instance's keys and had to be flushed with --remote, because wrangler 4 points KV commands at the local simulator by default; and the first real hosted session came back signed ES256, which the API's algorithm allow-list would have rejected (hosted-user-tokens-es256-not-hs256). The dashboard rebuild was verified by a zero-hit grep for the old host in the served bundle; the API by a real hosted session getting 200 from /me.
Watch out.
- The old instance's anon key may not equal the new one (ours had been left on the demo key). Set it explicitly in each client's build env; don't inherit it from a stale dotenv.
- Auth hooks, MFA settings, SMTP and redirect URLs are instance config, not rows. Re-enable them by hand (custom-access-token-hook-is-instance-config).
- Retire the old box in pieces: stop only the Supabase containers first. Anything co-located (our mail relay was) keeps running.
Related: hosted-user-tokens-es256-not-hs256 · storage-buckets-do-not-migrate-with-the-database · custom-access-token-hook-is-instance-config