Move a Supabase database to Neon, RDS or your own server
Move a Supabase database to Neon, RDS or your own server
Supabase is Postgres, so the data moves cleanly. What does not move is the part of Supabase
that is not Postgres — and knowing which is which before you start is the difference
between a migration and an outage.
Read this whole page first. It is short, and the surprise here is not technical difficulty,
it is scope.
What actually travels
Your public schema travels. Tables, data, indexes, constraints, sequences, views,
functions and custom types in public are migrated, foreign keys are re-applied in
dependency order, and row counts and foreign-key totals are verified against the source
before the migration can report success.
Supabase's own schemas do not. auth, storage, realtime, vault, graphql and
friends are Supabase's implementation of Supabase. Their contents are not copied. This is
deliberate, not an oversight — those schemas are owned by Supabase-internal roles
(supabase_admin, supabase_auth_admin, authenticated, anon, service_role) that
exist on no other Postgres in the world, and a copy of them on a plain Postgres server is
inert at best.
The practical consequence, stated plainly:
Your users table in auth.users does not come with you, and neither does Supabase
Auth. If your application authenticates through Supabase, moving the database is not
the whole job — you need somewhere for authentication to live afterwards, and a plan for
the user records. Decide that before you migrate, not after.
The same is true of Storage (files live in Supabase's object storage, not in Postgres),
Realtime, and Edge Functions. If your public tables are the whole of your application's
state, this is a clean move. If Supabase is also your auth provider and your file store,
the database is one of three things you are moving.
Row-level security policies are a middle case. Policies attached to your public
tables are part of that schema, but they are written against Supabase's roles. On a target
that has no authenticated or anon role, a policy referencing one cannot be created.
Either create matching roles on the target first, or plan to rewrite the policies for
whatever your new authorisation model is. Check what you have:
SELECT schemaname, tablename, policyname, roles
FROM pg_policies
WHERE schemaname = 'public';
Use the direct connection string, not the pooler
Supabase gives you more than one connection string. The one on port 6543 is the
transaction-mode pooler. The one on port 5432 is the direct connection.
Migrate through the direct connection on 5432. A transaction pooler does not carry
session state between statements, and a migration depends on it — session parameters,
schema application, and a long-lived COPY per table. Through the pooler these fail, and
they tend to fail part-way through a large table rather than immediately, which is the
worst way for something to fail.
Supabase also offers an IPv6-only direct host on some projects, with the pooler as the
IPv4 path. If your target or your network cannot reach the direct host at all, use the
session-mode pooler (port 5432 on the pooler hostname) rather than transaction mode.
You will find both strings under Project Settings → Database. SSL is required and is
used by default; don't add ?sslmode=disable.
A note on the size you are quoted
Postgres reports size per database, not per schema, and that is what pre-flight measures.
On Supabase this includes the auth, storage and realtime schemas, so the figure you
are quoted can be meaningfully larger than the public data that will actually be copied —
on a small project with a large auth.audit_log_entries table, noticeably so.
We would rather tell you that than let you discover it on an invoice. If the gap matters,
you can see it before you commit:
SELECT nspname AS schema, pg_size_pretty(SUM(pg_total_relation_size(c.oid))) AS size
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r','m')
GROUP BY 1 ORDER BY SUM(pg_total_relation_size(c.oid)) DESC;
Vacuuming or trimming Supabase's audit log before you migrate is a legitimate way to
shrink both the quote and the time it takes.
Extensions
Supabase enables a generous set of extensions by default, and your schema may quietly
depend on one. pgcrypto, uuid-ossp, postgis, pg_trgm and citext are the usual
suspects. A column typed by an extension cannot be created on a target that does not have
that extension installed, so the schema apply stops there.
List what is enabled, install the same set on the target, then start:
SELECT extname FROM pg_extension WHERE extname NOT IN ('plpgsql');
Note that several Supabase-default extensions exist to serve Supabase's own schemas
(pg_graphql, pgsodium, supabase_vault). You do not need those on the target unless
your own tables use them.
Cutting over
A migration copies the database as it was when the copy ran; writes that land on Supabase
afterwards stay there. So take a window rather than racing them: stop writes, migrate,
verify, repoint, resume.
Because you are probably moving authentication as well, sequence the two deliberately
rather than together. Moving the database while auth still points at Supabase is a
recoverable state; moving both at once, at speed, is how you end up unable to tell which
half broke.
After you land
Set up an automated backup of the new database before you send it traffic. Supabase's
backups covered the old one; nothing covers the new one until you say so, and the first
week on a new host is exactly when you want a restorable copy you control.