Postgres · Self-hosting · Field notes
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
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:
| Component | What it actually does | Image I ran |
|---|---|---|
| Postgres | your data, your policies | postgres 17, host install |
| PostgREST | turns supabase.from() into SQL over HTTP | postgrest:v16.4 |
| GoTrue | accounts, passwords, JWTs | supabase/auth:v2.197.0 |
| storage-api | file uploads, signed URLs | storage-api:v1.79.22 |
| Edge Functions | Deno workers | deferred |
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 magicI 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
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:
anon, authenticated, service_role.
Every policy names them. They do not exist on a fresh cluster.
auth schema and its functionsauth.uid(), auth.role(), auth.jwt(),
auth.email(), and an auth.users table for foreign
keys to point at.
supabase_realtime publicationSupabase creates it for you. Migrations that call
ALTER PUBLICATION supabase_realtime ADD TABLE assume it exists.
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.
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
These are the ones worth paying someone for. Each produces a working deployment that is quietly wrong.
Trap 1
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
"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.
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
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
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:
service_role key you can only export through their own tooling.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.