Draft, not yet published
Migrating from Supabase to your own Postgres: the complete guide
7 July 2026· 8 min read · by Stackbastion
You want off Supabase and onto your own Postgres, maybe for cost, maybe for control, maybe because you outgrew the plan. You dumped the database, it restored fine, and then you hit the wall nobody warned you about: your logins stopped working, your security rules vanished, and you’re not sure how to get back if this goes wrong. The data was the easy part. Here’s the map for the parts that actually decide whether this migration works.
Why the data move is the easy 20%
Copying rows out of Supabase and into a Postgres server you control is genuinely simple. Supabase is Postgres. A pg_dump and a pg_restore handle your tables, indexes, and data cleanly, and for a small app the whole thing takes minutes.
The hard 80% is everything Supabase did for you that isn’t stored in your tables:
- Auth. Supabase Auth (GoTrue) manages your users, passwords, sessions, and OAuth logins. It lives in the
authschema and is wired into the platform. Move off Supabase and you have to decide who runs auth now. - Row Level Security. Your RLS policies enforce who can see what, right inside the database, using
auth.uid(). That function comes from Supabase’s auth layer. Move the database andauth.uid()no longer exists, so every policy silently stops working the way it did. - The APIs. Supabase auto-generates a REST and realtime API from your schema. Your frontend probably calls it directly. Your own Postgres has none of that.
Nail these three and the migration works. Skip them and you’ll ship an app that “restored successfully” and locks out every user on day one. Let’s take them as decisions, not commands.
Step 1: get the data across (the easy part, done right)
Dump the schema and data from Supabase. Use the connection string from your Supabase project settings:
# Dump everything except Supabase-internal schemas you don't own
pg_dump "postgresql://postgres:[PASSWORD]@db.[PROJECT].supabase.co:5432/postgres" \
--schema=public \
--no-owner --no-privileges \
--format=custom \
--file=supabase_dump.pgdump
Restore into your new Postgres 17 server:
createdb myapp
pg_restore --no-owner --no-privileges --dbname=myapp supabase_dump.pgdump
Note the --schema=public. You’re deliberately not dragging Supabase’s auth, storage, and realtime schemas along, because those belong to the platform you’re leaving. Your users’ rows live in auth.users, though, so you do need to export those separately if you plan to keep the accounts (more on that below). Verify row counts match before you trust anything:
-- Run on both old and new, compare the numbers
select relname, n_live_tup
from pg_stat_user_tables
order by relname;
Step 2: the auth decision tree
This is the biggest fork in the road. You have three realistic paths. Pick based on how much you want to run yourself.
Option A: keep Supabase Auth, move only the database.
Supabase lets you use its auth service against an external Postgres in some setups, or you keep Supabase purely as an auth provider while your data lives elsewhere. You keep the hosted login flow and only migrate data.
- Good when: you like the auth experience and just wanted database control or lower data costs.
- Trade-off: you’re not fully off Supabase. You still depend on it for logins, still pay for that piece, and still have a vendor in your critical path. This is the smallest change and the least clean break.
Option B: switch to a managed auth provider (Auth.js or Clerk).
Hand auth to a dedicated service. Clerk gives you hosted UI and user management. Auth.js (NextAuth) is open source and runs in your own app.
- Good when: you want to be fully off Supabase but don’t want to run auth infrastructure yourself.
- Trade-off: you have to migrate users. Passwords are stored as hashes you usually can’t move directly, so most teams either trigger a password reset for everyone on first login, or import users and force a reset. OAuth users (Google, GitHub) re-link cleanly by email. Budget for the user-communication side of this, not just the code.
Option C: self-host GoTrue (the engine behind Supabase Auth).
Supabase Auth is open source. You can run the same GoTrue service on your own server against your own Postgres.
- Good when: you want zero vendor lock-in and full control, and you’re comfortable running another service (Docker, config, SMTP for emails, keeping it patched).
- Trade-off: it’s the most work and the most ongoing responsibility. You own uptime, security patches, and email deliverability. Because it’s the same engine, your existing
auth.userstable and password hashes can often move across intact, which makes it the cleanest path for keeping logins working without a mass password reset. That’s the upside that offsets the operational cost.
Decision shortcut: want the smallest change, pick A. Want off Supabase with the least ops, pick B. Want full control and no forced password reset, pick C.
Step 3: translate RLS to real authorization
Here’s the quiet failure. Your policies look like this today:
create policy "users read own orders"
on orders for select
using (auth.uid() = user_id);
That auth.uid() is a Supabase function that reads the logged-in user from the request’s JWT. On your own Postgres, without Supabase’s request layer, auth.uid() doesn’t exist, and the policy either errors or, worse, quietly stops filtering. Your security disappears and nothing crashes to warn you.
You have two ways to keep the same protection.
Move authorization into your app layer. Instead of the database deciding who sees what, your backend does. Every query that reads user data adds the where user_id = $currentUser filter in code. This is how most apps outside Supabase work. It’s simple and explicit, but the safety now depends on you never forgetting the filter, so centralize it (a single data-access layer, not scattered queries).
Keep RLS, but provide the user yourself. You can keep database-level RLS if your app sets the current user on each connection. Replace auth.uid() with a value your app passes in:
-- Your app runs this at the start of each request's transaction
set local app.current_user_id = 'the-logged-in-user-uuid';
-- Rewrite policies to read that setting instead of auth.uid()
create policy "users read own orders"
on orders for select
using (current_setting('app.current_user_id', true)::uuid = user_id);
This keeps your defense inside the database, where a forgotten where clause can’t leak data. It’s more setup but a stronger guarantee. Whichever you choose, test it: log in as user A and confirm you cannot read user B’s rows. Do this before launch, not after.
Step 4: replace the auto-generated API
Your frontend probably calls Supabase’s generated REST client directly. On your own stack you need to provide those endpoints. The pragmatic path is a thin backend (a small Node or similar service) that exposes the handful of routes your app actually uses. You don’t need to rebuild every table’s CRUD, just the operations your frontend really calls. Audit your frontend for supabase.from(...) calls to see the real surface area, which is usually smaller than you’d guess.
Step 5: the rollback plan (keep Supabase warm)
This is the step that lets you sleep. Do not delete or pause your Supabase project the moment the new server responds. Run a period where Supabase stays live and untouched as your fallback.
The safe cutover:
- Stand up the new Postgres and the new auth path in parallel. Supabase keeps serving production.
- Do a final data sync right before cutover, so the new database matches.
- Flip your app’s config (connection string, auth provider) to the new stack.
- Watch logins, reads, and writes closely for at least a few days.
- If anything’s wrong, flip the config back to Supabase. Because you never touched it, rollback is instant.
- Only after a clean stretch (a week is reasonable) do you decommission Supabase.
The mistake that turns a migration into an incident is tearing down the old system before the new one has proven itself under real traffic. Keeping Supabase warm costs you a few more days of one bill. That’s cheap insurance against a lockout you can’t undo.
Or, we do it for you
Moving off Supabase with auth, RLS, and a tested rollback intact is exactly the kind of job our Rescue service handles end to end, so your users never notice the switch. See what a Rescue engagement covers.
FAQ
Can I just use pg_dump and be done?
For the data, yes. For a working app, no. pg_dump moves your tables and rows perfectly, but it doesn’t move auth, and it doesn’t make your RLS policies work on a database that no longer has auth.uid(). The dump is step one of five, not the whole job.
Will my users have to reset their passwords?
It depends on the auth path. If you self-host GoTrue (the same engine Supabase uses), the password hashes usually move across and users notice nothing. If you switch to a different provider like Clerk or Auth.js, the hashes typically don’t transfer, so most teams force a one-time reset. Plan the user communication either way.
What happens to my Row Level Security after the move?
It stops working the way it did, quietly. Your policies rely on auth.uid(), which is a Supabase function that won’t exist on your own Postgres. You either move authorization into your app’s query layer or rewrite the policies to read the current user from a setting your app provides. Test that one user can’t read another’s data before you go live.
How long should I keep Supabase running after cutover?
At least a week of clean production traffic on the new stack. Keep the Supabase project live and untouched the entire time so a rollback is just a config flip. The cost of an extra week is trivial next to the cost of a lockout you can’t reverse because you already tore down the old system.