Postgres · Self-hosting · Field notes

Leaving Supabase Cloud

I moved a production multi-tenant SaaS off Lovable and Supabase Cloud onto a single server I own. Ninety-nine tables, 228 row-level-security policies, and a browser that talks to Postgres directly. Here is what actually broke — and the one idea that saved me from rewriting every policy.

The reframe

Supabase is five programs, not one

This is the thing you have to internalise before you plan anything. What the dashboard presents as a single product is a Postgres database plus four services around it:

ComponentWhat it actually doesImage I ran
Postgresyour data, your policiespostgres 17, host install
PostgRESTturns supabase.from() into SQL over HTTPpostgrest:v16.4
GoTrueaccounts, passwords, JWTssupabase/auth:v2.197.0
storage-apifile uploads, signed URLsstorage-api:v1.79.22
Edge FunctionsDeno workersdeferred

All four are open source and run anywhere. That is the good news, and it is why this migration is possible at all.

The bad news is the shape of the app. This one is a Vite front end with no backend of its own: the browser queries the database directly. I counted 272 calls to .from(), 28 rpc calls and 26 storage calls in the client bundle. Every one of those is a request that arrives at Postgres carrying a JWT, and every one is gated by RLS.

So moving only the database gets you nothing. auth.uid() returns NULL, all 228 policies evaluate false, and every screen in the app goes blank while reporting no error at all.

The idea that made it cheap

auth.uid() is not platform magic

I expected this to be the expensive part. It was twenty lines.

auth.uid() reads a session variable. That is the whole mechanism. Whoever holds the connection sets the JWT claims on it before running your query, and the function reads them back out:

CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid
LANGUAGE sql STABLE AS $$
  SELECT COALESCE(
    NULLIF(current_setting('request.jwt.claim.sub', true), ''),
    (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')
  )::uuid
$$;

PostgREST already does the setting part — it runs SET LOCAL request.jwt.claims from the bearer token on every request. Give it a function that reads the same variable, and every policy you wrote against Supabase keeps working, word for word.

I did not rewrite a single one of the 228 policies. That realisation is the difference between a week of work and a quarter of it.

Note that the function reads both claim shapes. That is not defensive coding; it is load-bearing, for a reason I'll come back to.

The compatibility layer

Five dependencies nobody documents

Your migrations were written against a database that already had things in it. Replay them on clean Postgres and they fail, one at a time, each failure revealing a dependency that appears in neither your migration files nor the project docs. I found these in five consecutive runs:

  1. The roles

    anon, authenticated, service_role. Every policy names them. They do not exist on a fresh cluster.

  2. The auth schema and its functions

    auth.uid(), auth.role(), auth.jwt(), auth.email(), and an auth.users table for foreign keys to point at.

  3. The supabase_realtime publication

    Supabase creates it for you. Migrations that call ALTER PUBLICATION supabase_realtime ADD TABLE assume it exists.

  4. Extensions in their own schema

    pgcrypto installed into public gets dropped along with it when you rebuild. Cloud puts extensions in a separate extensions schema; copy that, or you will lose gen_random_uuid() halfway through a reload.

  5. Grants, which fail before the policies

    Without GRANT on tables to authenticated, the request is refused at the table level with permission denied — before RLS is ever evaluated. It reads exactly like broken RLS. It isn't. I lost an hour here.

All of it lives in one idempotent SQL file I apply before the migrations. Idempotent matters — see the third trap.

Failures that produce no error

Three traps that break the app silently

These are the ones worth paying someone for. Each produces a working deployment that is quietly wrong.

Trap 1

GoTrue overwrites auth.uid() with a version PostgREST 16 cannot use

GoTrue runs its own migrations on the auth schema at startup and replaces your function. Its version reads only the flat variable request.jwt.claim.sub. PostgREST 16 doesn't set that one — it puts the entire claim set into request.jwt.claims as JSON.

Result: auth.uid() returns NULL on every request, every policy closes, and the app shows empty screens to logged-in users. Nothing is logged. It does not look like a crash; it looks like the data vanished.

The fix is ordering, not fighting. Give GoTrue ownership, let it migrate, then restore your function bodies with CREATE OR REPLACE — which preserves the object identity the policies are bound to, so the swap is invisible to them. Re-apply after every GoTrue image bump.

Trap 2

session_replication_role = replica does not skip foreign keys

The standard advice for loading a dump with circular references. It disables triggers on writes. But pg_dump adds foreign keys at the end, as ALTER TABLE ADD CONSTRAINT, and that performs a full validation pass regardless.

Mine failed on users.auth_uid → auth.users, because the auth schema isn't in a public-only dump. The cure wasn't disabling anything: I pulled the thirteen required UUIDs straight out of the COPY public.users block in the dump file and seeded auth.users before loading.

Trap 3

pg_dump --clean takes your default privileges with it

--clean drops schema public, and ALTER DEFAULT PRIVILEGES … IN SCHEMA public goes with it. Your grants were correct an hour ago and are gone now, and the symptom is the same permission denied as trap one's.

So the compatibility layer gets applied twice — once before the migrations, once after the data load. Which is why it has to be idempotent from the first line.

One more, less dramatic: pg_net is written in Rust by Supabase and isn't in the PGDG repos. The migrations create it; nothing calls it, because the real HTTP calls came from pg_cron jobs configured through the dashboard. I replaced it with a stub that raises rather than returning NULL. A database making silent outbound HTTP calls that quietly do nothing is a thing you would hunt for weeks.

Verification

How I knew it had actually arrived

"The app loads" is not evidence. I built the schema twice by independent routes — replaying all 145 migrations, and restoring a full dump — and compared the results against each other and against the source.

99tables, both routes
10,407rows, matched per table
1434columns of 1434
230foreign keys, 0 orphans
228RLS policies
26functions

Row counts per table, not in aggregate — an aggregate match hides two errors that cancel. Orphan check across every foreign key at once. Encoding verified on non-Latin text, which in this app is most of it.

Accounts were recreated through the GoTrue admin API with their original UUIDs — it accepts an explicit id. That one detail meant the application's own user table needed no edits and its foreign key reconnected on all thirteen.

An unplanned finding

The storage policies were open to the public

Reading every policy carefully is part of the work. That is how I found this one, which had been live for seven months:

CREATE POLICY "read_documents" ON storage.objects
  FOR SELECT USING (bucket_id = 'documents');

No TO clause, so it applies to every role — including anon. No tenant check, so it spans every customer. And the anon key ships inside the compiled front-end bundle, where anyone can read it.

Anyone at all could list and download every document belonging to every tenant. The neighbouring bucket's policies were written correctly, so this was one slip rather than a pattern — which is exactly how these survive review.

Rewriting it meant deriving the tenant from the object path, and the paths had two different shapes. I checked my formula against all 360 live objects before switching anything: 360 of 360 agreed with the tenant recorded in the database. Then TO authenticated plus the tenant predicate.

Scope

What this costs, honestly

The parts that go quickly: the compatibility layer, the dump and load, the container manifests, routing all four services under one hostname so CORS never enters the picture, TLS, and a deploy pipeline.

The parts that take real time:

For this app the whole thing ran about a week, and that included finding and closing the security hole.

About

I run three Kubernetes clusters and around twenty services in production, across Postgres, MySQL, Redis and object storage. This was the first migration I did off a vibe-coding platform, and it went well enough that I'm offering it as a fixed-price piece of work.

If you are staring at a Supabase bill and a codebase you did not entirely write, here is what that looks like.