<?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: Docto24</title>
    <description>The latest articles on DEV Community by Docto24 (@docto24).</description>
    <link>https://dev.to/docto24</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%2F4161454%2F46312fb1-cd3f-4a7c-8a91-91e6c796629a.png</url>
      <title>DEV Community: Docto24</title>
      <link>https://dev.to/docto24</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/docto24"/>
    <language>en</language>
    <item>
      <title>Self-Hosting Supabase for Health Data under GDPR: Lessons From Running a Telemedicine Platform</title>
      <dc:creator>Docto24</dc:creator>
      <pubDate>Sun, 04 Oct 2026 11:17:15 +0000</pubDate>
      <link>https://dev.to/docto24/self-hosting-supabase-for-health-data-under-gdpr-lessons-from-running-a-telemedicine-platform-33o6</link>
      <guid>https://dev.to/docto24/self-hosting-supabase-for-health-data-under-gdpr-lessons-from-running-a-telemedicine-platform-33o6</guid>
      <description>&lt;p&gt;We run &lt;a href="https://docto24.de" rel="noopener noreferrer"&gt;Docto24&lt;/a&gt;, a German telemedicine platform: patients book online consultations, licensed physicians review medical questionnaires and issue e-prescriptions, and pharmacies fulfil orders. Almost everything that flows through the system is &lt;strong&gt;health data&lt;/strong&gt;, the most sensitive category of personal data under the GDPR.&lt;/p&gt;

&lt;p&gt;Our backend is &lt;strong&gt;Supabase, self-hosted on our own servers in Germany&lt;/strong&gt;: Postgres, GoTrue (auth), PostgREST, Storage, Realtime and around 100 Deno edge functions. This post covers why we chose that setup, how it is structured, and the pitfalls that matter when the data in your tables is someone's medical history.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disclaimer: this is an engineering write-up, not legal advice. Talk to your data protection officer before you put health data anywhere.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;## Why self-host at all?&lt;/p&gt;

&lt;p&gt;Supabase Cloud is excellent, offers EU regions and a DPA. For many health apps it is a perfectly valid choice. We self-host for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A short, simple sub-processor chain.&lt;/strong&gt; Under Art. 28 GDPR, every party that processes data on your behalf needs a data processing agreement, and you need to understand &lt;em&gt;their&lt;/em&gt; sub-processors too. With self-hosting, the chain for our core data is: us → a German hosting provider. That makes conversations with physicians, pharmacies and auditors much shorter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data residency we can prove.&lt;/strong&gt; "Where exactly is the database, and who can access it?" is the first question in every partner due-diligence. "This server in Germany, these two people" is an easy answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control over versions and upgrades.&lt;/strong&gt; In a regulated product, we want to decide when auth or the API layer changes, and we want to test it on staging first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trade-off is real: &lt;strong&gt;you become the operations team.&lt;/strong&gt; Backups, upgrades, monitoring, secret rotation and incident response are now your job. If you do not have the capacity for that, managed hosting in an EU region is the safer GDPR choice, because an unpatched self-hosted stack is worse than a well-run cloud one.&lt;/p&gt;

&lt;p&gt;## Architecture overview&lt;/p&gt;

&lt;p&gt;Our setup, simplified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two separate VPS environments&lt;/strong&gt; (staging and production), both in Germany, each running the full Supabase stack via Docker Compose, managed with Dokploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinned image versions&lt;/strong&gt; for every service (&lt;code&gt;supabase/postgres&lt;/code&gt;, &lt;code&gt;gotrue&lt;/code&gt;, &lt;code&gt;postgrest&lt;/code&gt;, &lt;code&gt;storage-api&lt;/code&gt;, &lt;code&gt;realtime&lt;/code&gt;, &lt;code&gt;edge-runtime&lt;/code&gt;, &lt;code&gt;kong&lt;/code&gt;…). Every upgrade is logged in a &lt;code&gt;versions.md&lt;/code&gt; with the previous version, so we can roll back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge functions as our own Docker image&lt;/strong&gt;, built in CI and pushed to a container registry, deployed to staging first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations as code&lt;/strong&gt;: every schema change, RLS policy and function is a SQL migration in Git (we are well past 900 of them), applied by the deploy pipeline via &lt;code&gt;supabase db push&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging is not public&lt;/strong&gt;: it sits behind an extra authentication layer, and it never contains real patient data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing exotic, and that is the point. The interesting part is everything that sits &lt;em&gt;on top of&lt;/em&gt; this setup.&lt;/p&gt;

&lt;p&gt;## Row Level Security: your actual security perimeter&lt;/p&gt;

&lt;p&gt;With Supabase, the browser talks to your database through PostgREST. That means &lt;strong&gt;Row Level Security is not a nice-to-have, it &lt;em&gt;is&lt;/em&gt; your authorization layer.&lt;/strong&gt; If a policy is wrong, the API exposes the data directly.&lt;/p&gt;

&lt;p&gt;The basic pattern is well known:&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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prescriptions&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"patients read own prescriptions"&lt;/span&gt;
    &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prescriptions&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;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;patient_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are the pitfalls that are less obvious.&lt;/p&gt;

&lt;p&gt;### Pitfall 1: &lt;code&gt;auth.uid() is null&lt;/code&gt; does not mean "backend"&lt;/p&gt;

&lt;p&gt;It is tempting to write a policy or function guard like "if there is no user, it must be our backend calling":&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="c1"&gt;-- ❌ Looks like "service role only", but it is not.&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;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;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;patient_id&lt;/span&gt; &lt;span class="o"&gt;=&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;strong&gt;anonymous&lt;/strong&gt; request also has &lt;code&gt;auth.uid() = null&lt;/code&gt;. That condition does not lock anyone out: it opens the table to everyone who has your public anon key, which is everyone who can open your website.&lt;/p&gt;

&lt;p&gt;If something is meant for the backend only, do not express that through &lt;code&gt;auth.uid()&lt;/code&gt;. Use the &lt;code&gt;service_role&lt;/code&gt; and &lt;strong&gt;revoke access from everyone else&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;### Pitfall 2: functions are executable by everyone by default&lt;/p&gt;

&lt;p&gt;Postgres grants &lt;code&gt;EXECUTE&lt;/code&gt; on new functions to &lt;code&gt;PUBLIC&lt;/code&gt;. In Supabase, the default privileges also cover &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt;. Combine that with &lt;code&gt;SECURITY DEFINER&lt;/code&gt; (the function runs with the owner's rights and &lt;strong&gt;bypasses RLS&lt;/strong&gt;) and you get an API endpoint that skips all your policies.&lt;/p&gt;

&lt;p&gt;For any internal &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function:&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;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recalculate_settlement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_id&lt;/span&gt; &lt;span class="n"&gt;uuid&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;void&lt;/span&gt;
  &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
  &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
  &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;          &lt;span class="c1"&gt;-- prevent search_path hijacking&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;begin&lt;/span&gt;
    &lt;span class="c1"&gt;-- ...&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recalculate_settlement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recalculate_settlement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And audit regularly for anything that slipped through:&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="c1"&gt;-- SECURITY DEFINER functions that anonymous users can call&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regprocedure&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;### Pitfall 3: "nobody calls this" is not the same as "nothing depends on this"&lt;/p&gt;

&lt;p&gt;Before revoking a function or dropping a column, &lt;code&gt;grep&lt;/code&gt; across your frontend is not enough. &lt;strong&gt;RLS policies, triggers and other functions can call it too.&lt;/strong&gt; Check &lt;code&gt;pg_policies&lt;/code&gt; and the function bodies in &lt;code&gt;pg_proc.prosrc&lt;/code&gt; as well. Otherwise your clean-up change silently breaks a policy and users suddenly see empty lists.&lt;/p&gt;

&lt;p&gt;### Pitfall 4: RLS fails silently&lt;/p&gt;

&lt;p&gt;When a policy denies access, PostgREST does not return a 403. It returns &lt;strong&gt;an empty array&lt;/strong&gt;. That is good for security and terrible for debugging: "the table is empty" and "you are not allowed to see anything" look identical.&lt;/p&gt;

&lt;p&gt;Two habits help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When something shows up empty, first check &lt;strong&gt;which role actually ran the query&lt;/strong&gt; before concluding there is no data.&lt;/li&gt;
&lt;li&gt;Make sure every table in &lt;code&gt;public&lt;/code&gt; actually has RLS turned on:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;### Test your policies like code&lt;/p&gt;

&lt;p&gt;We test critical policies with &lt;strong&gt;pgTAP&lt;/strong&gt;: switch to a role, run the query, assert the result.&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;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;is_empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prescriptions&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'anonymous users see no prescriptions'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="s1"&gt;'{"sub": "00000000-0000-0000-0000-000000000001", "role": "authenticated"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;results_eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prescriptions&lt;/span&gt;
       &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;patient_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'00000000-0000-0000-0000-000000000001'&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'patients cannot see other patients&lt;/span&gt;&lt;span class="se"&gt;''&lt;/span&gt;&lt;span class="s1"&gt; prescriptions'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One hard-earned rule: &lt;strong&gt;run test suites against a disposable database container, never against a shared environment.&lt;/strong&gt; Test scripts that disable triggers or insert fixtures can commit side effects if a single transaction wrapper is missing.&lt;/p&gt;

&lt;p&gt;## The GDPR checklist beyond the database&lt;/p&gt;

&lt;p&gt;RLS protects the API. GDPR asks broader questions. Here is what we look at for health data:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legal basis and purpose (Art. 6 + Art. 9).&lt;/strong&gt; Health data needs an Art. 9 exception, typically explicit consent or the provision of health care. Store &lt;strong&gt;which version of which consent&lt;/strong&gt; a user accepted, with a timestamp, in the database. You will need it.&lt;br&gt;
  &lt;strong&gt;Technical and organisational measures (Art. 32).&lt;/strong&gt; Document them: encryption in transit (TLS everywhere, including between your reverse proxy and services), encrypted backups, access control to servers (SSH keys only, fail2ban, no shared accounts), secret rotation and logging of admin access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs are personal data too.&lt;/strong&gt; Supabase's logging stack (Logflare/Vector) and your edge-function logs can easily contain emails, IPs or request bodies. Decide what gets logged, how long it is kept and who can read it. Do not log full request payloads that contain medical answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage buckets.&lt;/strong&gt; Medical documents and prescriptions belong in &lt;strong&gt;private&lt;/strong&gt; buckets with short-lived signed URLs. Audit the &lt;code&gt;public&lt;/code&gt; flag on every bucket regularly, because one bucket created as "public, just for testing" is enough to cause a problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit trail.&lt;/strong&gt; For anything medically or legally relevant (a prescription issued, an identity verification approved, a status changed by an admin), write an &lt;strong&gt;append-only audit log&lt;/strong&gt; in the database: who, what, when, old value, new value. When a regulator or a patient asks "who changed this?", you need an answer that does not depend on server logs that have long been rotated away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletion and data subject rights (Art. 15–17).&lt;/strong&gt; Account deletion in Supabase means more than &lt;code&gt;auth.users&lt;/code&gt;. Personal data is spread across your own tables, storage objects and email logs. Build deletion and export as real, tested functions, and remember that some medical records have &lt;strong&gt;legal retention periods&lt;/strong&gt; that override deletion requests. In that case you restrict access instead of deleting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your hosting provider's DPA (Art. 28).&lt;/strong&gt; Self-hosting does not remove the provider from the equation. Sign the hosting provider's data processing tables, storage objects and email logs. Build deletion and export as real, tested functions, and remember that some medical records have &lt;strong&gt;legal retention periods&lt;/strong&gt; that override deletion requests. In that case you restrict access instead of deleting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your hosting provider's DPA (Art. 28).&lt;/strong&gt; Self-hosting does not remove the provider from the equation. Sign the hosting provider's data processing agreement and keep it on file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational lessons from self-hosting
&lt;/h2&gt;

&lt;p&gt;Things the docs do not warn you about loudly enough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker restart&lt;/code&gt; does not reload &lt;code&gt;.env&lt;/code&gt;.&lt;/strong&gt; Containers keep the environment they were created with. After changing secrets or config, recreate the container (&lt;code&gt;docker compose up -d&lt;/code&gt;) and &lt;strong&gt;verify the value inside the running container&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kong only loads the plugins you list.&lt;/strong&gt; If your &lt;code&gt;kong.yml&lt;/code&gt; uses a plugin that is missing from &lt;code&gt;KONG_PLUGINS&lt;/code&gt;, routes fail in confusing ways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never expose Studio publicly.&lt;/strong&gt; Bind it to localhost or a private network and reach it via an SSH tunnel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin versions and upgrade on staging first.&lt;/strong&gt; GoTrue and PostgREST upgrades can change behaviour, for example around embedded resources or error formats. A changelog of image versions makes rollbacks boring, which is what you want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor from the outside.&lt;/strong&gt; Your server can go down for reasons that have nothing to do with your code: provider issues, billing, network. An external uptime check that pages a human is cheap insurance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups only count once you have restored them.&lt;/strong&gt; Automated &lt;code&gt;pg_dump&lt;/code&gt; or WAL archiving to a separate location in the EU, encrypted, plus a regular restore test into a fresh container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate secrets and know how to do it.&lt;/strong&gt; JWT secret, service-role key, dashboard credentials. Write a script before you need it in a hurry, because rotating the JWT secret logs out every user, and you want to do that on purpose, not in a panic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Would we do it again?
&lt;/h2&gt;

&lt;p&gt;Yes, with open eyes. Self-hosted Supabase gives us a modern developer experience (Postgres, auth, storage and edge functions out of the box) together with full control over where health data lives and who can touch it.&lt;/p&gt;

&lt;p&gt;But the database is the easy part. Most of the GDPR work lives in RLS discipline, logging hygiene, deletion flows, audit trails and boring operational routines. If you are building in health tech, budget for that from day one.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>postgres</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
