Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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

LayerLines / count hereWho owns it on CloudMoves to self-hosted unchanged?
Schema + policies + RPCs8 files, 271 lines of SQLYouYes — it is supabase/migrations/
Client code4 files, 102 lines (src/lib/supabase/)YouYes — only the URL changes
Session refreshsrc/proxy.ts, 42 linesYouYes
Postgres itself (version, backups, failover)—SupabaseNo — becomes your pager
GoTrue / PostgREST / Storage upgrades—SupabaseNo — becomes your upgrade schedule
Connection pooling—SupabaseNo — you deploy PgBouncer or Supavisor
The advisor linter0 lints todaySupabaseNo — there is no self-hosted equivalent
Dashboard-only auth settings~6 settingsSharedNo — 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 constraintHow it shows upWhat we did
Default statement timeoutcanceling statement due to statement timeout on a query that worked locallyFixed the query, not the timeout — see the triage order
Hosted email rate limitsSignup bursts hit email rate limit exceededTreated the limit as real and handled the error path
Shared project during developmentNo throwaway database to breakKept DB-shaped logic pure and testable without one
The client SDK's weight68 KiB over the wire, 255 KiB parsed, on every pageDeferred the import (below)
Egress and row quotasBilled by usageNothing 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

SymptomLikely causeFix
Advisor flags "function search_path mutable"A SECURITY DEFINER function without a pinned pathset search_path = public, pg_temp on the function
Advisor flags "auth RLS initplan"auth.uid() called per rowWrap it: (select auth.uid())
A query times out on Cloud but not locallyCloud's statement timeout plus a policy forcing a scanFix the query first; raise the timeout last
Every route 500s after deployThe two public Supabase env vars are unset, and the proxy runs on every requestSet them before the first deploy
Service-role key appears in a client bundleA Client Component imported the admin clientKeep the server-only import; let the build fail
A fresh project works but auth redirects failDashboard-only settings are not in your migrationsRe-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.

Keep reading

Tutorial10 min read

Supabase Deploy: Three Variables Decide Whether Any Page Loads

Eight migrations, seven dashboard settings a migration can't carry, and why a missing Supabase env var takes down 111 product pages instead of one.

Read more
Tutorial9 min read

Supabase Email Rate Limit Exceeded: Why This Codebase Doesn't Fight It

Supabase's built-in email provider caps auth emails project-wide. This codebase relies on that limit by design (R-13) instead of building a bespoke one — and builds one anyway, elsewhere.

Read more