We run Docto24, 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 health data, the most sensitive category of personal data under the GDPR.
Our backend is Supabase, self-hosted on our own servers in Germany: 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.
Disclaimer: this is an engineering write-up, not legal advice. Talk to your data protection officer before you put health data anywhere.
## Why self-host at all?
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:
- A short, simple sub-processor chain. Under Art. 28 GDPR, every party that processes data on your behalf needs a data processing agreement, and you need to understand their 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.
- Data residency we can prove. "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.
- Control over versions and upgrades. In a regulated product, we want to decide when auth or the API layer changes, and we want to test it on staging first.
The trade-off is real: you become the operations team. 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.
## Architecture overview
Our setup, simplified:
- Two separate VPS environments (staging and production), both in Germany, each running the full Supabase stack via Docker Compose, managed with Dokploy.
-
Pinned image versions for every service (
supabase/postgres,gotrue,postgrest,storage-api,realtime,edge-runtime,kong…). Every upgrade is logged in aversions.mdwith the previous version, so we can roll back. - Edge functions as our own Docker image, built in CI and pushed to a container registry, deployed to staging first.
-
Migrations as code: 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
supabase db push. - Staging is not public: it sits behind an extra authentication layer, and it never contains real patient data.
Nothing exotic, and that is the point. The interesting part is everything that sits on top of this setup.
## Row Level Security: your actual security perimeter
With Supabase, the browser talks to your database through PostgREST. That means Row Level Security is not a nice-to-have, it is your authorization layer. If a policy is wrong, the API exposes the data directly.
The basic pattern is well known:
alter table public.prescriptions enable row level security;
create policy "patients read own prescriptions"
on public.prescriptions
for select
to authenticated
using (patient_id = (select auth.uid()));
Here are the pitfalls that are less obvious.
### Pitfall 1: auth.uid() is null does not mean "backend"
It is tempting to write a policy or function guard like "if there is no user, it must be our backend calling":
-- ❌ Looks like "service role only", but it is not.
using (auth.uid() is null or patient_id = auth.uid())
An anonymous request also has auth.uid() = null. 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.
If something is meant for the backend only, do not express that through auth.uid(). Use the service_role and revoke access from everyone else.
### Pitfall 2: functions are executable by everyone by default
Postgres grants EXECUTE on new functions to PUBLIC. In Supabase, the default privileges also cover anon and authenticated. Combine that with SECURITY DEFINER (the function runs with the owner's rights and bypasses RLS) and you get an API endpoint that skips all your policies.
For any internal SECURITY DEFINER function:
create or replace function internal.recalculate_settlement(p_id uuid)
returns void
language plpgsql
security definer
set search_path = '' -- prevent search_path hijacking
as $$
begin
-- ...
end;
$$;
revoke execute on function internal.recalculate_settlement(uuid)
from public, anon, authenticated;
grant execute on function internal.recalculate_settlement(uuid)
to service_role;
And audit regularly for anything that slipped through:
-- SECURITY DEFINER functions that anonymous users can call
select p.oid::regprocedure as function
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prosecdef
and has_function_privilege('anon', p.oid, 'execute');
### Pitfall 3: "nobody calls this" is not the same as "nothing depends on this"
Before revoking a function or dropping a column, grep across your frontend is not enough. RLS policies, triggers and other functions can call it too. Check pg_policies and the function bodies in pg_proc.prosrc as well. Otherwise your clean-up change silently breaks a policy and users suddenly see empty lists.
### Pitfall 4: RLS fails silently
When a policy denies access, PostgREST does not return a 403. It returns an empty array. That is good for security and terrible for debugging: "the table is empty" and "you are not allowed to see anything" look identical.
Two habits help:
- When something shows up empty, first check which role actually ran the query before concluding there is no data.
- Make sure every table in
publicactually has RLS turned on:
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
and not c.relrowsecurity;
### Test your policies like code
We test critical policies with pgTAP: switch to a role, run the query, assert the result.
begin;
select plan(2);
set local role anon;
select is_empty(
$$ select * from public.prescriptions $$,
'anonymous users see no prescriptions'
);
set local role authenticated;
set local request.jwt.claims to '{"sub": "00000000-0000-0000-0000-000000000001", "role": "authenticated"}';
select results_eq(
$$ select count(*)::int from public.prescriptions
where patient_id <> '00000000-0000-0000-0000-000000000001' $$,
$$ values (0) $$,
'patients cannot see other patients'' prescriptions'
);
select * from finish();
rollback;
One hard-earned rule: run test suites against a disposable database container, never against a shared environment. Test scripts that disable triggers or insert fixtures can commit side effects if a single transaction wrapper is missing.
## The GDPR checklist beyond the database
RLS protects the API. GDPR asks broader questions. Here is what we look at for health data:
Legal basis and purpose (Art. 6 + Art. 9). Health data needs an Art. 9 exception, typically explicit consent or the provision of health care. Store which version of which consent a user accepted, with a timestamp, in the database. You will need it.
Technical and organisational measures (Art. 32). 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.
Logs are personal data too. 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.
Storage buckets. Medical documents and prescriptions belong in private buckets with short-lived signed URLs. Audit the public flag on every bucket regularly, because one bucket created as "public, just for testing" is enough to cause a problem.
Audit trail. For anything medically or legally relevant (a prescription issued, an identity verification approved, a status changed by an admin), write an append-only audit log 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.
Deletion and data subject rights (Art. 15–17). Account deletion in Supabase means more than auth.users. 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 legal retention periods that override deletion requests. In that case you restrict access instead of deleting.
Your hosting provider's DPA (Art. 28). 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 legal retention periods that override deletion requests. In that case you restrict access instead of deleting.
Your hosting provider's DPA (Art. 28). Self-hosting does not remove the provider from the equation. Sign the hosting provider's data processing agreement and keep it on file.
Operational lessons from self-hosting
Things the docs do not warn you about loudly enough:
-
docker restartdoes not reload.env. Containers keep the environment they were created with. After changing secrets or config, recreate the container (docker compose up -d) and verify the value inside the running container. -
Kong only loads the plugins you list. If your
kong.ymluses a plugin that is missing fromKONG_PLUGINS, routes fail in confusing ways. - Never expose Studio publicly. Bind it to localhost or a private network and reach it via an SSH tunnel.
- Pin versions and upgrade on staging first. 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.
- Monitor from the outside. 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.
-
Backups only count once you have restored them. Automated
pg_dumpor WAL archiving to a separate location in the EU, encrypted, plus a regular restore test into a fresh container. - Rotate secrets and know how to do it. 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.
Would we do it again?
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.
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.
Top comments (0)