14 min read
The short version: the database is the easy part, and authentication is the hard part. Everything else sits between those two.
Supabase is not one product, and that is what makes leaving it non-obvious. You adopted a Postgres database, an auth service, a file store, a realtime channel and a serverless runtime, all behind one key. Leaving means replacing five things, and they do not come apart at the same speed.
Here they are in the order that actually matters, with what each one costs.
This is the part everyone worries about and it is the least of it. Supabase Postgres is Postgres. Nothing proprietary lives in your tables.
# Schema and data, from the connection string in Settings → Database
pg_dump --no-owner --no-privileges \
"postgresql://postgres:[PASSWORD]@db.[REF].supabase.co:5432/postgres" \
> dump.sql
psql "$NEW_DATABASE_URL" < dump.sqlTwo things to strip before restoring, because they are Supabase's and not yours:
auth, storage and realtime schemas. They belong to services you are leaving. Dump public only unless you are deliberately taking the user table with you — see the next section.Extensions are worth a check — pgcrypto, uuid-ossp and pg_stat_statements are usually there, and CREATE EXTENSION on the new host is a one-liner.
Your users are rows in auth.users, a schema you do not own. This is where "we'll migrate next sprint" becomes "we'll migrate next quarter", so decide it first.
The good news: Supabase stores password hashes as bcrypt in auth.users.encrypted_password. Bcrypt is portable — the hash carries its own algorithm, cost and salt. Any bcrypt library on any backend can verify it. So you can move users without forcing a password reset, which is the difference between a migration nobody notices and a support incident.
-- Export what you actually need. Do NOT take the whole auth schema.
COPY (
SELECT id, email, encrypted_password, email_confirmed_at, created_at,
raw_user_meta_data
FROM auth.users
WHERE deleted_at IS NULL
) TO STDOUT WITH CSV HEADER;On the new side, verify against the same hash and rehash on first successful login if you are moving to Argon2:
const ok = await bcrypt.compare(password, user.legacyPasswordHash);
if (ok && user.legacyPasswordHash) {
// Transparent upgrade — the user never notices.
await users.setPassword(user.id, await argon2.hash(password));
await users.clearLegacyHash(user.id);
}What does not come with you:
auth.identities and are tied to Supabase's registered OAuth apps. You register your own apps and re-link on next login by matching the verified email. Users who only ever used social login have no password — they need a first-login link, not a reset.Budget more time here than for everything else combined.
Files come out with the S3-compatible API or the client SDK. The trap is not the bytes, it is the links.
If your app stores full Supabase URLs in the database — https://[ref].supabase.co/storage/v1/object/public/... — every one of those rows breaks the day you turn the project off. Old emails, old exports, anything cached anywhere.
The fix is the thing you should have done anyway: store the key, build the URL at render time.
// Instead of a stored absolute URL
const src = `${STORAGE_BASE}/${product.imageKey}`;Do that migration before moving hosts and the storage cutover becomes a file copy and a changed environment variable. Do it after and you are writing a data-fixing script under time pressure.
Signed URLs for private files are a different mechanism on every provider, so that code gets rewritten regardless. It is small.
Edge Functions are Deno. If they are thin — a webhook receiver, a scheduled job — they become routes on your backend and shrink in the process, because they stop needing their own auth handling.
Realtime is the one to be honest about: if you genuinely use Postgres change subscriptions, you are replacing a real feature and it is work. But a large share of apps subscribe to a table and simply re-fetch. That is polling with extra steps, and plain polling or a small SSE endpoint replaces it in an afternoon.
Check which one you have before assuming the expensive answer.
Do not do a big-bang cutover. This sequence keeps you online at every step:
pg_dump and a connection-string change, with the application already pointed at your own layer.Step 2 is what makes this survivable. Once your backend owns the API surface, every remaining move is an infrastructure change instead of an application change.
Being straight about it: if Supabase is working and the bill is fine, staying is the correct decision. It is good software and self-inflicted migrations are how small teams lose a quarter.
The reasons that actually justify moving:
anon key is public by design and RLS is the only wall behind it — a model that suits a browser talking to a database, and stops suiting an application with a domain.If none of those is true, close this page and ship features.
If the destination is a backend you own, Checkout Kit and Booking Kit ship exactly that shape — a standalone Go or Node server, your own auth and field-level RBAC, on a database that is only ever reached from your side.