Run your own PostgREST against a Supabase project
You’ll turn off the managed Data API on a Supabase project and run PostgREST yourself, in Docker, against the same Postgres - then front it with a db-pre-request IP filter and an nginx rate limiter. This is move 3 in Locking down Supabase: the database is the boundary: the managed HTTP tier has no network perimeter, so an IP-restricted or rate-limited REST layer has to be one you own.
Prerequisites: a Supabase project (any plan) and Docker.
What you need, and where it is
Section titled “What you need, and where it is”The commands reference these. Run every SQL block in the SQL Editor (Dashboard -> SQL Editor).
| Value | Where to get it |
|---|---|
$REF - the project ref (a 20-character id) | your project URL, or Settings -> API |
$ANON - the anon (or publishable) API key | Settings -> API, explained in Understanding API keys |
| A personal access token (PAT) for the Management API calls | the dashboard, under Account -> Access Tokens |
| The database password | set when you created the project; reset it under Settings -> Database if you do not have it |
$REGION and the session-pooler connection string | the Connect button on the dashboard, Session mode - see Connecting to Postgres |
Supabase ships three Postgres roles these steps use: anon (an unauthenticated caller), authenticated (a logged-in user), and service_role (bypasses RLS). PostgREST connects as an “authenticator” role and switches to anon or authenticated per request.
Everything below was run on a throwaway micro project in ap-southeast-1 on 2026-08-28.
Component versions
Section titled “Component versions”| Component | Version | Note |
|---|---|---|
| PostgREST | postgrest/postgrest:v16.2 | official image |
| nginx | nginx:1.31.4-trixie | the rate limiter |
| Supabase Postgres | 17.6 | reported by the PostgREST connect log |
Architecture
Section titled “Architecture”Step 1: turn the managed Data API off
Section titled “Step 1: turn the managed Data API off”Empty the exposed schema so the managed PostgREST stops answering. Through the Management API (PATCH /v1/projects/{ref}/postgrest with {"db_schema": ""}), or the Dashboard’s Data API toggle. Confirm it wedges:
curl -s -o /dev/null -w '%{http_code}\n' \ -H "apikey: $ANON" "https://$REF.supabase.co/rest/v1/your_table?select=id"# -> 503 (PGRST002) within a few secondsAuth, Storage, Realtime and Edge Functions keep answering - this lever is PostgREST-only, which is the whole reason you are replacing it.
Step 2: get the session-pooler connection
Section titled “Step 2: get the session-pooler connection”PostgREST holds a connection pool, so connect through the session pooler (Supavisor, port 5432), not the transaction pooler. Copy the exact string from the dashboard’s Connect button, Session mode - do not hand-build it, since the pooler hostname and the postgres.<ref> username format are project-specific. It has this shape:
postgres://postgres.<ref>:<db-password>@aws-0-<region>.pooler.supabase.com:5432/postgresThe direct host db.<ref>.supabase.co is IPv6-only; the pooler is IPv4, which a default Docker bridge can reach. Step 3 swaps the postgres user in this string for a dedicated role.
Step 3: create a connection role, then run PostgREST
Section titled “Step 3: create a connection role, then run PostgREST”PostgREST connects as an authenticator role and switches to anon for unauthenticated requests. Connect as a dedicated NOSUPERUSER role that can only SET ROLE to anon/authenticated - never as postgres, which is a superuser and bypasses RLS and every grant, so RLS on your own PostgREST would do nothing. Create the role first:
create role pgrst_auth noinherit login password 'a-strong-password';grant anon, authenticated to pgrst_auth;Then run PostgREST connecting as it (through the pooler the username is <role>.<ref>). PGRST_DB_PRE_REQUEST names the filter function you install in step 5 - set it now, since PostgREST reads it at config load.
docker run -d --name own-postgrest -p 3000:3000 \ -e PGRST_DB_URI="postgres://pgrst_auth.$REF:a-strong-password@aws-0-$REGION.pooler.supabase.com:5432/postgres" \ -e PGRST_DB_SCHEMAS=public \ -e PGRST_DB_ANON_ROLE=anon \ -e PGRST_DB_PRE_REQUEST=public.ip_filter \ postgrest/postgrest:v16.2docker logs own-postgrest | grep 'Successfully connected'# -> Successfully connected to PostgreSQL 17.6Step 4: grant anon and reload the schema cache
Section titled “Step 4: grant anon and reload the schema cache”PostgREST cached the schema at start with anon holding no grants. Grant read on the tables you expose and reload:
grant usage on schema public to anon;grant select on public.your_table to anon;notify pgrst, 'reload schema';GET http://localhost:3000/your_table?select=id now returns rows the managed endpoint no longer will.
Step 5: the db-pre-request IP filter
Section titled “Step 5: the db-pre-request IP filter”This is the control the managed tier will not run. The function reads the forwarding header and raises with a PT-prefixed SQLSTATE, which PostgREST maps to the matching HTTP status.1 An IP ban (block one address) keeps the filter out of the way of trusted callers:
create or replace function public.ip_filter() returns void language plpgsql as $$declare xff text := current_setting('request.headers', true)::json ->> 'x-forwarded-for';begin if xff is not null and xff like '%203.0.113.9%' then raise sqlstate 'PT403' using message = 'ip banned: ' || xff; end if;end$$;grant execute on function public.ip_filter() to anon;Verify the ban and the pass:
curl -s -o /dev/null -w 'banned=%{http_code}\n' \ -H 'x-forwarded-for: 203.0.113.9' 'http://localhost:3000/your_table?select=id' # -> 403curl -s -o /dev/null -w 'allowed=%{http_code}\n' 'http://localhost:3000/your_table?select=id' # -> 200For an allowlist instead of a ban, invert the test (raise unless xff is in your set). Read the client IP from x-forwarded-for only if the edge in front overwrites it; if the edge appends, a client can spoof it.
Step 6: rate limit at the edge
Section titled “Step 6: rate limit at the edge”Rate limiting lives in front of PostgREST - nginx, a Cloudflare Worker, or Upstash all work; nginx limit_req is the self-contained one. It only counts traffic through it, which is why it works now (the origin is closed) and could not work in front of the managed endpoint.
events {}http { limit_req_zone $binary_remote_addr zone=rl:10m rate=2r/s; limit_req_status 429; server { listen 8080; location / { limit_req zone=rl burst=2 nodelay; proxy_pass http://host.docker.internal:3000; } }}docker run -d --name own-ratelimit --add-host=host.docker.internal:host-gateway \ -p 8080:8080 -v "$PWD/ratelimit.nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.31.4-trixieA 15-request burst against :8080 returns 2 served and 13 rejected (429).
Verification
Section titled “Verification”| Check | Expected |
|---|---|
| Managed REST | 503 PGRST002 |
| Your PostgREST direct | 200 with rows |
Spoofed x-forwarded-for at the filter | 403 |
| Burst through nginx | some 429, some 200 |
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”- The
db-pre-requestfunction must exist for the container’s whole life.PGRST_DB_PRE_REQUESTis read at config load, and PostgREST calls the function on every request - drop it and every request errors. Create it before the container serves traffic, and do not drop it while the container runs. - nginx
limit_reqrejects with503by default. Setlimit_req_status 429for the correct status. - Connect as the authenticator role from step 3, never as
postgres.postgresis a superuser that bypasses RLS and every grant, so an RLS-based lockdown on your own PostgREST would be silently defeated;service_rolebypasses RLS too, so keep it out of the connection and the request path. Authorization is yours to enforce now - RLS on anon/authenticated, the pre-request function, or the edge - and the managed platform’s role separation does not carry over automatically. - A fresh table is invisible until
notify pgrst, 'reload schema'. PostgREST caches the schema; grants and new tables need the reload. - This wires anonymous reads. For authenticated user requests, point PostgREST’s JWT verification (
PGRST_JWT_SECRET, orPGRST_JWKS_URIfor asymmetric keys) at your token issuer’s key so it validates the bearer token and switches toauthenticated- your own issuer, or the Supabase-issued tokens if you keep Supabase Auth. - Put the pooler behind PrivateLink to take the database connection itself off the public internet - then both the REST layer and the connection are private.
Teardown
Section titled “Teardown”docker rm -f own-postgrest own-ratelimitDrop the role with drop role pgrst_auth;, and re-enable the managed Data API (Settings, or PATCH .../postgrest with db_schema back to public) if you want it back.
File reference
Section titled “File reference”The reproducible form is the supabase-lab security-lockdown experiment: s04-self-hosted-postgrest.ts (Data API off, container, IP filter), s05-rate-limit.ts (nginx burst), ratelimit.nginx.conf, s06-postgrest-role.ts (the connection-role check), and the postgrest-up / ratelimit-up Makefile targets.
References
Section titled “References”References
Section titled “References”-
PostgREST, “Pre-Request,” PostgREST Documentation. https://postgrest.org/en/stable/references/transactions.html#pre-request ↩