Skip to content

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.

The commands reference these. Run every SQL block in the SQL Editor (Dashboard -> SQL Editor).

ValueWhere to get it
$REF - the project ref (a 20-character id)your project URL, or Settings -> API
$ANON - the anon (or publishable) API keySettings -> API, explained in Understanding API keys
A personal access token (PAT) for the Management API callsthe dashboard, under Account -> Access Tokens
The database passwordset when you created the project; reset it under Settings -> Database if you do not have it
$REGION and the session-pooler connection stringthe 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.

ComponentVersionNote
PostgRESTpostgrest/postgrest:v16.2official image
nginxnginx:1.31.4-trixiethe rate limiter
Supabase Postgres17.6reported by the PostgREST connect log
clientnginx limit_req:8080PostgREST :3000db-pre-request filtersession pooleraws-0-<region>.pooler.supabase.com:5432managed Data APIOFF (503)wedged

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:

Terminal window
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 seconds

Auth, Storage, Realtime and Edge Functions keep answering - this lever is PostgREST-only, which is the whole reason you are replacing it.

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/postgres

The 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.

Terminal window
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.2
docker logs own-postgrest | grep 'Successfully connected'
# -> Successfully connected to PostgreSQL 17.6

Step 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.

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:

Terminal window
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' # -> 403
curl -s -o /dev/null -w 'allowed=%{http_code}\n' 'http://localhost:3000/your_table?select=id' # -> 200

For 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.

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; }
}
}
Terminal window
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-trixie

A 15-request burst against :8080 returns 2 served and 13 rejected (429).

CheckExpected
Managed REST503 PGRST002
Your PostgREST direct200 with rows
Spoofed x-forwarded-for at the filter403
Burst through nginxsome 429, some 200
  • The db-pre-request function must exist for the container’s whole life. PGRST_DB_PRE_REQUEST is 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_req rejects with 503 by default. Set limit_req_status 429 for the correct status.
  • Connect as the authenticator role from step 3, never as postgres. postgres is a superuser that bypasses RLS and every grant, so an RLS-based lockdown on your own PostgREST would be silently defeated; service_role bypasses 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, or PGRST_JWKS_URI for asymmetric keys) at your token issuer’s key so it validates the bearer token and switches to authenticated - 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.
Terminal window
docker rm -f own-postgrest own-ratelimit

Drop 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.

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.

  1. PostgREST, “Pre-Request,” PostgREST Documentation. https://postgrest.org/en/stable/references/transactions.html#pre-request