<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Philip SEOcean</title>
    <description>The latest articles on DEV Community by Philip SEOcean (@philipseocean).</description>
    <link>https://dev.to/philipseocean</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4154641%2F31c20c8c-87b1-4a70-be81-02f5dcac6af0.png</url>
      <title>DEV Community: Philip SEOcean</title>
      <link>https://dev.to/philipseocean</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/philipseocean"/>
    <language>en</language>
    <item>
      <title>Leaving Supabase Cloud: the three failures that produce no error</title>
      <dc:creator>Philip SEOcean</dc:creator>
      <pubDate>Thu, 01 Oct 2026 11:55:24 +0000</pubDate>
      <link>https://dev.to/philipseocean/leaving-supabase-cloud-the-three-failures-that-produce-no-error-119b</link>
      <guid>https://dev.to/philipseocean/leaving-supabase-cloud-the-three-failures-that-produce-no-error-119b</guid>
      <description>&lt;p&gt;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 front end that queries the database directly from the browser.&lt;/p&gt;

&lt;p&gt;It took about a week. Most of that week went into four or five specific problems, each of which produced a working deployment that was quietly wrong. This is what they were.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase is five programs, not one
&lt;/h2&gt;

&lt;p&gt;Internalise this before planning anything. What the dashboard presents as a single product is a Postgres database plus four services around it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it actually does&lt;/th&gt;
&lt;th&gt;What I ran&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Postgres&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;your data, your policies&lt;/td&gt;
&lt;td&gt;postgres 17, host install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PostgREST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;turns &lt;code&gt;supabase.from()&lt;/code&gt; into SQL over HTTP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgrest/postgrest:v16.4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GoTrue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;accounts, passwords, JWTs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;supabase/auth:v2.197.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;storage-api&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;file uploads, signed URLs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;supabase/storage-api:v1.79.22&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Edge Functions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deno workers&lt;/td&gt;
&lt;td&gt;deferred&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four are open source and run anywhere. That is why this migration is possible at all.&lt;/p&gt;

&lt;p&gt;The complication 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 &lt;strong&gt;272 calls to &lt;code&gt;.from()&lt;/code&gt;&lt;/strong&gt;, 28 &lt;code&gt;rpc&lt;/code&gt; calls and 26 storage calls in the client bundle. Every one of those arrives at Postgres carrying a JWT, and every one is gated by RLS.&lt;/p&gt;

&lt;p&gt;So moving only the database gets you nothing. &lt;code&gt;auth.uid()&lt;/code&gt; returns NULL, all 228 policies evaluate false, and every screen goes blank while reporting no error.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;auth.uid()&lt;/code&gt; is not platform magic
&lt;/h2&gt;

&lt;p&gt;I expected this to be the expensive part. It was twenty lines.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;auth.uid()&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;STABLE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request.jwt.claim.sub'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request.jwt.claims'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'sub'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgREST already does the setting part — it runs &lt;code&gt;SET LOCAL request.jwt.claims&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I did not rewrite a single one of the 228 policies.&lt;/strong&gt; That is the difference between a week of work and a quarter of it.&lt;/p&gt;

&lt;p&gt;Note that the function reads &lt;em&gt;both&lt;/em&gt; claim shapes. That is not defensive coding. It is load-bearing, for a reason in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five dependencies nobody documents
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The roles.&lt;/strong&gt; &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt;, &lt;code&gt;service_role&lt;/code&gt;. Every policy names them. They do not exist on a fresh cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The &lt;code&gt;auth&lt;/code&gt; schema and its functions.&lt;/strong&gt; &lt;code&gt;auth.uid()&lt;/code&gt;, &lt;code&gt;auth.role()&lt;/code&gt;, &lt;code&gt;auth.jwt()&lt;/code&gt;, &lt;code&gt;auth.email()&lt;/code&gt;, and an &lt;code&gt;auth.users&lt;/code&gt; table for foreign keys to point at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The &lt;code&gt;supabase_realtime&lt;/code&gt; publication.&lt;/strong&gt; Supabase creates it for you. Migrations that call &lt;code&gt;ALTER PUBLICATION supabase_realtime ADD TABLE&lt;/code&gt; assume it exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Extensions in their own schema.&lt;/strong&gt; &lt;code&gt;pgcrypto&lt;/code&gt; installed into &lt;code&gt;public&lt;/code&gt; gets dropped along with it when you rebuild. Cloud puts extensions in a separate &lt;code&gt;extensions&lt;/code&gt; schema. Copy that, or you lose &lt;code&gt;gen_random_uuid()&lt;/code&gt; halfway through a reload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Grants, which fail &lt;em&gt;before&lt;/em&gt; the policies.&lt;/strong&gt; Without &lt;code&gt;GRANT&lt;/code&gt; on tables to &lt;code&gt;authenticated&lt;/code&gt;, the request is refused at the table level with &lt;code&gt;permission denied&lt;/code&gt; — before RLS is ever evaluated. It reads exactly like broken RLS. It isn't. I lost an hour here.&lt;/p&gt;

&lt;p&gt;All of it lives in one idempotent SQL file applied before the migrations. Idempotent matters — see trap three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three traps that break the app silently
&lt;/h2&gt;

&lt;p&gt;These are the ones worth paying attention to. Each produces a working deployment that is quietly wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap 1: GoTrue overwrites &lt;code&gt;auth.uid()&lt;/code&gt; with a version PostgREST 16 cannot use
&lt;/h3&gt;

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

&lt;p&gt;Result: &lt;code&gt;auth.uid()&lt;/code&gt; 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.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Trap 2: &lt;code&gt;session_replication_role = replica&lt;/code&gt; does not skip foreign keys
&lt;/h3&gt;

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

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

&lt;h3&gt;
  
  
  Trap 3: &lt;code&gt;pg_dump --clean&lt;/code&gt; takes your default privileges with it
&lt;/h3&gt;

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

&lt;p&gt;So the compatibility layer gets applied &lt;strong&gt;twice&lt;/strong&gt; — once before the migrations, once after the data load. Which is why it has to be idempotent from the first line.&lt;/p&gt;

&lt;h3&gt;
  
  
  One more, less dramatic
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pg_net&lt;/code&gt; 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 &lt;code&gt;pg_cron&lt;/code&gt; jobs configured through the dashboard. I replaced it with a stub that &lt;em&gt;raises&lt;/em&gt; rather than returning NULL. A database making silent outbound HTTP calls that quietly do nothing is a thing you would hunt for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I knew it had actually arrived
&lt;/h2&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tables, both routes&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows, matched per table&lt;/td&gt;
&lt;td&gt;10,407&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columns&lt;/td&gt;
&lt;td&gt;1434 of 1434&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign keys&lt;/td&gt;
&lt;td&gt;230, zero orphans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RLS policies&lt;/td&gt;
&lt;td&gt;228&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Functions&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Row counts &lt;strong&gt;per table&lt;/strong&gt;, 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  An unplanned finding
&lt;/h2&gt;

&lt;p&gt;Reading every policy carefully is part of the work. That is how I found this, which had been live for seven months:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"read_documents"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'documents'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;If you are on Supabase and have never read your storage policies line by line, read them today. This one cost nothing to find and would have cost everything to discover the other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually costs
&lt;/h2&gt;

&lt;p&gt;Fast: the compatibility layer, the dump and load, the container manifests, routing all four services under one hostname so CORS never comes up, TLS, a deploy pipeline.&lt;/p&gt;

&lt;p&gt;Slow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reading every policy and every function, because that is where both the traps and the findings are.&lt;/li&gt;
&lt;li&gt;Files in storage buckets. Metadata lives in your database; the bytes live on their infrastructure, and without a &lt;code&gt;service_role&lt;/code&gt; key you can only export through their own tooling.&lt;/li&gt;
&lt;li&gt;Edge function secrets, which are write-only by design. You cannot read them back. Inventory what you set before you cut over.&lt;/li&gt;
&lt;li&gt;Scheduled jobs configured through the dashboard rather than in migrations. They are invisible in your repository and they will not come across.&lt;/li&gt;
&lt;li&gt;Any value baked into the front-end build. Build-time variables cannot be overridden by the environment at runtime: the container gets the new address and the browser still goes to the old one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;About a week in total, including finding and closing the security hole.&lt;/p&gt;




&lt;p&gt;I run three Kubernetes clusters and around twenty services in production across Postgres, MySQL, Redis and object storage. This was my first migration off a vibe-coding platform, and it went well enough that I write it down in case it saves someone the five runs it took me to find those dependencies.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments — including on the parts I deferred.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>devops</category>
      <category>selfhosting</category>
    </item>
  </channel>
</rss>
