Supabase Cloud vs Self-Hosted, Audited Against One Shipped Project
What the managed platform owns and what stays yours: 271 lines of SQL and 102 of client code move; the advisor linter, the pooler and six dashboard settings do not.
Supabase Cloud is the managed version of the Supabase stack: Postgres, Auth, Storage, the REST gateway and the connection pooler, run for you behind a dashboard. The components are the same open-source ones you can self-host, so the decision is never about features. It is about which parts of your project move to another host unchanged — and which parts do not move at all.
This storefront runs on Supabase Cloud (project asoc-marketplace). Below is an audit of exactly what that buys, counted in files, and what would still be ours on the day we migrated off.
The split, measured on this project
| Layer | Lines / count here | Who owns it on Cloud | Moves to self-hosted unchanged? |
|---|---|---|---|
| Schema + policies + RPCs | 8 files, 271 lines of SQL | You | Yes — it is supabase/migrations/ |
| Client code | 4 files, 102 lines (src/lib/supabase/) | You | Yes — only the URL changes |
| Session refresh | src/proxy.ts, 42 lines | You | Yes |
| Postgres itself (version, backups, failover) | — | Supabase | No — becomes your pager |
| GoTrue / PostgREST / Storage upgrades | — | Supabase | No — becomes your upgrade schedule |
| Connection pooling | — | Supabase | No — you deploy PgBouncer or Supavisor |
| The advisor linter | 0 lints today | Supabase | No — there is no self-hosted equivalent |
| Dashboard-only auth settings | ~6 settings | Shared | No — these are not in any file |
The last row is the one that decides most migrations, and it is the one nobody counts. Everything above it is portable because it is text in a repository. The settings in the hosted auth dashboard — redirect allowlist, site URL, provider secrets, email templates, rate limits — are configuration you clicked, living in Supabase's database rather than yours. They are also the reason deploying this schema to a fresh project is more than supabase db push.
What "managed" actually means here
Three concrete things, in the order they have mattered.
1. The advisor linter is free review you cannot buy elsewhere. Supabase Cloud runs a static analysis pass over your schema and reports missing RLS, mutable search_path on SECURITY DEFINER functions, unindexed foreign keys and policies that re-evaluate auth.uid() per row. Checked today, against this project:
security advisors: 0 lints
performance advisors: 0 lints
That zero is not luck, and two of the habits behind it came directly from earlier advisor warnings. Every SECURITY DEFINER function in this schema pins its search_path:
create or replace function public.record_download_within_limit(
p_user_id uuid, p_product_slug text, p_framework text,
p_version text, p_ip inet, p_user_agent text, p_limit int
) returns int
language plpgsql
security definer
set search_path = public, pg_temp
as $$
And every policy wraps the session lookup so Postgres evaluates it once per statement instead of once per row:
create policy "own orders" on public.orders
for select using ((select auth.uid()) = user_id);
The parentheses around select auth.uid() are the whole optimisation. Without them the function is called per row, which is invisible on six rows and expensive on sixty thousand. A self-hosted stack will happily run the slow version forever without telling you.
2. Postgres is still Postgres, which is why the schema is the portable part. Nothing in these 271 lines is a Supabase API. Three SECURITY DEFINER RPCs, six read-own for select policies, one citext column for case-insensitive buyer email, and a transaction-scoped advisory lock:
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
That line exists because the download endpoint originally read an hourly count and then inserted an audit row as two separate calls, so parallel requests could all pass the same check and all insert — a textbook check-then-act race. The fix was one RPC holding a per-user lock, and it is plain Postgres. Whoever hosts the database, that file is correct.
3. What Cloud does not remove is the surface area. Four client files, 102 lines, and the reason there are four rather than one is a trust boundary the hosting model has no opinion about:
import "server-only";
/**
* Service-role Supabase client — SERVER ONLY. Bypasses RLS.
*/
export function createAdminClient() {
return createSbClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } },
);
}
The server-only import is a build-time guard: if a Client Component ever imports this file, next build fails rather than shipping a key that bypasses every policy to the browser. Self-hosting does not make that key safer, and Cloud does not make it more dangerous. It is yours either way.
The costs that are specific to Cloud
Being honest about the managed side means naming where it bites.
| Cloud-specific constraint | How it shows up | What we did |
|---|---|---|
| Default statement timeout | canceling statement due to statement timeout on a query that worked locally | Fixed the query, not the timeout — see the triage order |
| Hosted email rate limits | Signup bursts hit email rate limit exceeded | Treated the limit as real and handled the error path |
| Shared project during development | No throwaway database to break | Kept DB-shaped logic pure and testable without one |
| The client SDK's weight | 68 KiB over the wire, 255 KiB parsed, on every page | Deferred the import (below) |
| Egress and row quotas | Billed by usage | Nothing yet — the storefront is 746 prerendered pages |
That fourth row is the measured one. @supabase/ssr plus auth-js was reaching /, /blog, /docs and /pricing — pages with no account UI at all — because two components imported the browser client at module scope. Both only touch it inside an effect, so the import can wait for the effect too:
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
export async function loadSupabaseClient(): Promise<BrowserClient> {
const { createClient } = await import("@/lib/supabase/client");
return createClient();
}
The cookie probe is a rendering shortcut, not a gate — a signed-out visitor never downloads the auth stack at all, and every real authorization decision stays on the server in authorizeDownload and the RLS policies. This cost is identical self-hosted; it is a bundle problem wearing a backend costume.
How this project is testable without either
The reason the hosting choice stays low-stakes here is that most of the logic does not need a database to be exercised:
$ npm test
Test Files 31 passed (31)
Tests 391 passed (391)
Duration 2.97s
391 tests, no container, no network, three seconds. The entitlement engine is a pure function over plain objects, and the payment webhook takes a two-method WebhookDb interface whose real Supabase-backed implementation is a separate 43-line file. If your own logic can only be checked against a live database, the hosting decision gets much heavier — and the local Docker stack stops being optional. Supabase local development is that question, separately.
Choosing, in one pass
- Cloud if your team has no on-call rotation for Postgres, you want the advisor linter, and you would rather spend the money than the attention. That is this project.
- Self-hosted if data residency or an existing database estate forces it, or if your usage makes the bill exceed the salary cost of operating it. Budget for upgrades, backups you have restored at least once, and a pooler.
- Both is normal: Cloud for production, Docker locally for schema work.
What should not drive the decision is the feature list, because it is the same list. Decide on operations.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Advisor flags "function search_path mutable" | A SECURITY DEFINER function without a pinned path | set search_path = public, pg_temp on the function |
| Advisor flags "auth RLS initplan" | auth.uid() called per row | Wrap it: (select auth.uid()) |
| A query times out on Cloud but not locally | Cloud's statement timeout plus a policy forcing a scan | Fix the query first; raise the timeout last |
| Every route 500s after deploy | The two public Supabase env vars are unset, and the proxy runs on every request | Set them before the first deploy |
| Service-role key appears in a client bundle | A Client Component imported the admin client | Keep the server-only import; let the build fail |
| A fresh project works but auth redirects fail | Dashboard-only settings are not in your migrations | Re-enter them; keep a checklist in the repo |
Frequently asked questions
Is Supabase Cloud just hosted Postgres? No. You also get GoTrue for auth, PostgREST for the generated API, Storage, the pooler, and the advisor linter. The database is the part you would most easily replace; the glue is the part you would miss.
Can I move from Cloud to self-hosted later?
The schema and client code move unchanged — here that is 271 lines of SQL and 102 lines of TypeScript. What does not move is anything you configured in the dashboard, plus the operational work Supabase was absorbing. Plan the migration around those, not around pg_dump.
Does self-hosting avoid the client bundle cost?
No. The 68 KiB is @supabase/ssr plus auth-js in the browser, and it is identical either way. Load it on demand instead.
Is the free tier enough to launch on? For a mostly-static storefront, usually yes — this one prerenders 746 pages and only eight routes touch the database at request time. Projects that pause on inactivity are the real constraint, so check the current plan limits before you depend on uptime.
Templates that keep the backend off the critical path
The pattern above — a prerendered marketing surface with the auth stack deferred behind it — is how the Next.js landing page templates and Tailwind landing page templates are built, so adding Supabase Cloud to one does not cost you the page speed you bought it for.
Templates in this post
ASoc Cover, ASoc Echo and ASoc Edge are Next.js + Tailwind landing page templates that render statically, so a Supabase backend stays out of the first paint.
