AWS PrivateLink to Supabase: an architecture reference
Supabase PrivateLink puts your database traffic on AWS VPC Lattice instead of the public internet: Supabase shares a Lattice Resource Configuration per project into your AWS account, and you attach it to a VPC endpoint in your own VPC.1 This is what it actually does once built, with numbers.
Everything below marked measured was validated on 2026-07-31 on a throwaway Team-plan org: a micro Supabase project in ap-southeast-1, an in-VPC t3.micro runner with no public IP (SSM-only), and an OpenTofu lab that builds and destroys the whole shape in minutes (VPC, endpoint, private hosted zone, runner, test suite). Claims about platform internals that were not re-tested are marked asserted and linked to their source.
TL;DR:
- The private path is boring in the good way. Direct Postgres (5432) and the project pooler (6543, transaction mode) both work through the endpoint with
sslmode=verify-full, provided you give the endpoint the project’s hostname via a Route53 private hosted zone (PHZ). The endpoint cert carries exactly one name:db.<ref>.supabase.co. - The private pooler path is faster than the public one. pgbench select-only, 4 clients: 3350 tps through the endpoint’s 6543 vs 2258 tps through public Supavisor - about 1.5x. Connect latency is a wash in-region (~30ms p50 either way).
supabase link --skip-pooleris the load-bearing CLI flag. A defaultlinktargets the public shared pooler, which is exactly what PrivateLink designs close off. With--skip-pooler,db pushruns over the endpoint.- You can close public DB access yourself, today. DB network restrictions narrowed to a break-glass /32 block the public direct and public pooler paths while the endpoint keeps serving. The guide currently says “contact support to disable public connectivity” - restrictions get you the same lockout for database traffic self-service.2
- Everything that is not the database socket stays public. API, Auth, Storage, Realtime keep working over the public internet by design.1 The Data API is not a PrivateLink path.
- The pooler ceiling is real and exact: first connection refusal at exactly 200 concurrent clients on micro, matching the published tier limit.3
- Restart downtime over the endpoint: 49-131s across three measured API-triggered restarts.
Topology
Section titled “Topology”The PHZ is the only piece the official walkthrough does not give you: it overrides db.<ref>.supabase.co inside your VPC so the endpoint presents a certificate your clients already trust. Dashed edges are public-internet paths - they keep working unless you close them.
Which connection path do I pick
Section titled “Which connection path do I pick”| Path | Reachable via | Measured p50 connect | Measured pgbench (4 clients) | Pick when |
|---|---|---|---|---|
| Private 5432 (direct) | Endpoint | 37ms | 3810 tps @ 1.05ms | Migrations, pg_dump, single sessions, anything wanting session state |
| Private 6543 (PgBouncer) | Endpoint | 31ms | 3350 tps @ 1.19ms | App servers in the VPC; highest concurrent-client count on the private path |
| Public Supavisor 6543 | Internet | 31ms | 2258 tps @ 1.77ms | IPv4-only networks with no PrivateLink; free on every tier4 |
| Public direct 5432 | Internet | n/a from an IPv4-only VPC | n/a | IPv6-capable networks, or with the IPv4 add-on5 |
| Data API (HTTPS) | Internet only | p50 18.6ms warm | n/a | PostgREST/Auth/Storage clients; never a PrivateLink path |
One row is deliberately “n/a”: the public direct endpoint is IPv6-only unless you buy the IPv4 add-on (~$4/mo), so an IPv4-only VPC has no public-direct path at all.5 Under PrivateLink this stops mattering - the endpoint already gives you in-VPC IPv4 on both ports, which makes the add-on moot for the VPC case.
What the endpoint actually is
Section titled “What the endpoint actually is”PrivateLink is an organisation-level configuration: for each project, Supabase creates a VPC Lattice Resource Configuration and shares it to your AWS account(s) via AWS Resource Access Manager (RAM).16 You consume it one of two ways: a VPC endpoint of type Resource, or an association to an existing Lattice service network.17
The lifecycle, measured:
- Add the AWS account in the dashboard (Settings > Integrations > AWS PrivateLink). Status goes CREATING -> READY in about 2 minutes.
- Accept the RAM share. The share name is
sspl-<ref>-<random>; the resource configuration is<org>-<ref>-rc.1 Acceptance is a hard ordering requirement:aws ram list-resourcesshows nothing while the invitation is PENDING (measured). Any automation must accept first, then look up the ARN - a declarativeaws_ram_resource_share_accepterdeadlocks on this. - Create the endpoint. Type
Resource, IPv4, one ENI per subnet, security group allowing both 5432 and 6543 inbound. The official walkthrough’s security-group step only opens 5432 (measured doc gap) - 6543 is the PgBouncer port and half the point. - DNS is on you. A Resource-type endpoint does not expose a usable
dns_entryvia the AWS API (measured on provider ~> 6.0); the reliable handle is the ENI private IPs. Put them in a PHZ A record fordb.<ref>.supabase.coat TTL 60 (the zone apex cannot be a CNAME, so the apex carries the IPs directly).
Managing it with Terraform
Section titled “Managing it with Terraform”Everything except one dashboard click is infrastructure-as-code, split across two providers. The supabase provider covers the project control plane over PAT-authenticated /v1; the AWS provider covers the entire consumer side. The gap is the association itself, as above.
| Layer | Resource / mechanism | Notes from the lab |
|---|---|---|
| Project + settings | supabase_project, supabase_settings (provider ~> 1.10) | Network restrictions go in as network = jsonencode({ restrictions = [...] }) - shape verified, applies clean, survives toggling |
| PrivateLink association | none - no provider resource, /platform rejects PATs | Dashboard click per AWS account; a gated restapi_object exists in the lab but stays off by default |
| RAM share | CLI, not declarative | aws_ram_resource_share_accepter cannot work here: the resource configuration is invisible until accepted, so accept-then-lookup is orchestration (a make arns step), not a resource |
| Endpoint | aws_vpc_endpoint with vpc_endpoint_type = "Resource" + resource_configuration_arn | No dns_entry output (provider ~> 6.0); SG with both 5432 and 6543 |
| DNS | aws_route53_zone + apex A record from data.aws_network_interface ENI IPs | Two-pass apply: for_each over the endpoint’s ENI IDs fails at plan while they are unknown - apply the endpoint first, then the rest resolves against real IDs |
The supabase provider has no privatelink resource at all, so do not go looking for it; the REST-shaped gap is exactly the one dashboard step. Full working module: the supabase-lab repo referenced under Reproducing.
DNS and TLS: verify-full through the endpoint
Section titled “DNS and TLS: verify-full through the endpoint”The endpoint terminates TLS with the project’s own certificate. Measured via openssl s_client -starttls postgres against the endpoint:
- Chain of 3 certificates; leaf subject and single SAN are exactly
db.<ref>.supabase.co. sslmode=verify-fullagainst the PHZ name: PASS.sslmode=verify-fullwithhost=<PHZ name> hostaddr=<ENI IP>(no DNS dependency): PASS.sslmode=verify-fullagainst the raw endpoint IP: fails by design (hostname mismatch). There is no IP SAN; an IP-based connection string can never do better thanverify-ca.
So the production pattern is: PHZ for db.<ref>.supabase.co in every VPC that consumes the endpoint, extract the chain once (lab used STARTTLS; production guidance is the dashboard CA download), and clients use the normal project hostname with sslmode=verify-full. Connection strings do not change between public and private clients - only the DNS answer does.
CLI and migration paths
Section titled “CLI and migration paths”Measured with the Supabase CLI on the in-VPC runner:
| Flow | Result | Path taken |
|---|---|---|
supabase link + db push (defaults) | works, but wrong path | default link targets the public shared pooler - useless in a locked-down design |
supabase link --skip-pooler + db push | works over the endpoint | direct connection via PHZ |
supabase db push --db-url postgres://...@db.<ref>.supabase.co:5432/... | works over the endpoint | no link, no Management API on the DB path |
--skip-pooler is the flag that makes CLI migrations PrivateLink-correct;8 without it, closing public access breaks your migration pipeline on the same day you close it.
A stale assumption to unlearn, measured: prepared statements now work on the transaction-mode pooler (PREPARE/EXECUTE succeeded on 6543). The old “transaction mode breaks prepared statements” advice predates current Supavisor/PgBouncer behaviour - re-test before cargo-culting prepare: false everywhere.
Closing public access
Section titled “Closing public access”The end state most PrivateLink adopters want is: database reachable only through the endpoint. Measured sequence:
- Apply DB network restrictions narrowed to a single break-glass /32.2
- Public direct (5432) and public Supavisor (6543) both stop connecting - restrictions cover pooled and direct routes.2
- The endpoint path is unaffected - the full connection matrix kept passing with restrictions in force.
That is the full lockout story for database traffic, self-service, no support ticket. The official guide still routes this through “contact support to disable public connectivity”;1 restrictions achieve the socket-level equivalent today, reversible in one API call. What the support action does beyond socket refusal is untested - if the requirement is public ingress removed rather than refused, the ticket is still the path.
Mind the scope: restrictions govern the database ports. The Data API stays reachable (it should - it is HTTP, and it is not a PrivateLink path). “Fully private” therefore means “private database socket”, not “private project”.
What stays public: the Data API
Section titled “What stays public: the Data API”PrivateLink covers direct Postgres and PgBouncer only; API, Auth, Storage, and Realtime keep operating over the public internet.1 Measured from inside the VPC (traffic egresses via NAT):
- Data API warm serial (n=120, c=1, anon key, single-row select): p50 18.6ms, p95 23.6ms, ~50 rps single-threaded. Cold request: 490ms total, dominated by a 449ms first-hit TTFB (schema cache warm-up); TLS handshake 34ms of it.
- PostgREST’s root
/rest/v1/requires the service_role key on the current platform: an anon key gets 401 with “Only theservice_roleAPI key can be used for this endpoint” (verified from off-VPC too). An anon-key reachability probe needs a real table. - For probe tables: SQL-created tables get
anonSELECT via default privileges and no RLS; dashboard-created tables get RLS enabled. A health-check table created by migration is anonymously readable by default - decide which you want.
Capacity and failure behavior
Section titled “Capacity and failure behavior”Pooler client ceiling. Ramping concurrent clients holding a transaction open against 6543 (transaction mode, micro): clean at 100/150/180, first connection refusal at exactly 200 - the published micro tier limit, to the client.3 Size serverless concurrency caps against this number, not against “we’ll see”.
Restart downtime. Three API-triggered project restarts,9 probed with select 1 every 2s over the endpoint: down windows of 49s, 72s, and 131s. Plan around the worst sample, not the median of three - and treat “restart drops the private path too” as expected: the endpoint stays up, the database behind it does not.
Reading the numbers
Section titled “Reading the numbers”- In-region is the cheat code. Connect p50 of ~30ms on every path is one region’s wire plus TLS; the throughput gap (1.5x) between private PgBouncer and public Supavisor is the interesting signal, not the absolute tps. A t3.micro runner and micro compute bound both ends; bigger tiers move the absolutes.
- The ceiling is a tier table, not a PrivateLink property - 200 is micro.3 The endpoint itself did not add a measurable refusal below the platform limit.
- Restart windows vary 3x across identical runs. The number to quote is a range, and the mechanism (API-triggered restart) matters.
- Beta. PrivateLink is beta, Team and Enterprise only, and the setup flow “may evolve”.1 Re-run the matrix before quoting these numbers at GA.
Gotchas
Section titled “Gotchas”Each with the failure mode you will actually see, the wrong path it sends you down, and the worst case worth assuming.
- Security group: 5432 only in the official walkthrough. The endpoint serves both direct Postgres (5432) and PgBouncer (6543) - the guide’s compatibility section says so - but its security-group step opens 5432 only.1 SGs drop rather than refuse, so with a verbatim walkthrough SG the 6543 path dies silently:
psql ... :5432works,psql ... :6543hangs to client timeout with no ICMP error, no Postgres error, and no log you own. The wrong debug path: the asymmetry (direct fine, pooler timing out) looks like a Lattice or pooler-platform problem, so you go spelunking in endpoint config and Supavisor status instead of the one-line SG gap. Worst case: you ship the private path for direct connections, the app’s pooler config fails over to the public Supavisor without anyone noticing, and the “private” deployment has been egressing DB traffic over the internet for weeks. Fix: one more inbound rule, TCP 6543, same source as 5432. Measured: the 6543 matrix test only passed after the second rule was added. - Association automation stops at the dashboard. The association lives behind undocumented
/platformroutes that authenticate with a dashboard session JWT, not the Management API’s PAT scheme. The failure mode: your pipeline 401s with “JWT could not be decoded” - which reads as a token problem, so you rotate the PAT, re-check org roles, burn an hour (a Developer-role 403 on project create is a different error and a red herring here). The tell: every/v1call succeeds with the same token while every/platformcall 401s. Worst case worth assuming: the click is permanent - N AWS accounts means N dashboard visits and N RAM shares to track, so write the association runbook as an ops procedure, not a pipeline stage. What is automatable: everything downstream (RAM accept, endpoint, DNS) is AWS-side. Measured: owner-role PAT rejected on the association POST, the status GET, and entitlements; the published/v1spec has no PrivateLink route at all (re-verified against the current spec). - RAM acceptance before ARN lookup. The shared resource configuration does not exist in your account’s view until the share is accepted -
list-resourcesreturns empty while the invitation is PENDING (measured). The failure mode: automation that looks up the ARN first sees nothing and fails or, worse, retries into a race. The structural trap: a declarativeaws_ram_resource_share_accepterdeadlocks, because the phase that could apply it needs the ARNs that only acceptance produces. Worst case: a re-run against an empty lookup quietly plans an endpoint replacement, your ENI IPs change, and every client in the VPC breaks at once. Fix: orchestrated accept-then-lookup (the lab’smake arns), with the producedarns.tfvarsgating phase 2. - Resource endpoints expose no API-visible DNS name. Gateway and Interface endpoints hand you
dns_entry; Resource-type endpoints do not (measured on AWS provider ~> 6.0 - the attribute comes back empty/unavailable). The failure mode: outputs and docs that assumedns_entrystall, and hand-built CNAMEs point at names that were never assigned. Worst case: the endpoint gets replaced (new ENIs, new IPs) and your hard-coded DNS or stale PHZ record points at dead addresses - every private client times out simultaneously, and TTL is the only thing between you and a long outage. Fix: PHZ apex A record carrying the ENI IPs at TTL 60, refreshed by the same apply that manages the endpoint; treat endpoint replacement as a DNS event. - IPv6-only public direct. Without the IPv4 add-on,
db.<ref>.supabase.copublishes only an AAAA record, so an IPv4-only client has no public-direct path at all.5 The failure mode is confusing because everything else works: the pooler is IPv4, the Data API is IPv4, so “the project is reachable” whilepsqldirect reports it cannot translate the hostname or hangs, depending on the stack. Worst case: you buy the add-on expecting dual-stack and discover it swaps the DNS answer to A-only at the record level - now your IPv6-native clients are the broken ones. Fix: Supavisor session mode for IPv4-only networks (free), the add-on only if public direct is genuinely required, and under PrivateLink neither matters - the endpoint already provides in-VPC IPv4 on both ports. - Default
supabase linkgoes public. A plainsupabase linkstores the shared-pooler (public Supavisor) connection.8 The failure mode:db pushworks fine all through development because public access is open, then the day you apply network restrictions the migration pipeline breaks - in CI, at deploy time, with a connection error that points at the pooler, not at your link config. Worst case: you believed migrations ran over the private path while they were egressing via NAT the whole time, and the lockout review signed off on it. Fix:supabase link --skip-pooler(direct via the PHZ) ordb push --db-url, and prove it by running the migration once with restrictions already closed. Measured: default link succeeded via public Supavisor (T08);--skip-poolerand--db-urlboth ran over the endpoint (T09, T10). - PostgREST root requires service_role. On the current platform,
/rest/v1/(the OpenAPI root) answers anon keys with 401 “Only theservice_roleAPI key can be used for this endpoint” (measured, on- and off-VPC). The failure mode: a health check or reachability probe against the root gets 401 with an “Invalid API key” body, which reads as key misconfiguration - so you rotate keys and re-check RLS while the project is fine. Worst case: monitoring pages you for a healthy project, or worse, someone “fixes” the probe by putting the service_role key into a client-side health check and ships it. Fix: probe a real table with the anon key - and decide deliberately, because a table created by migration getsanonSELECT via default privileges with no RLS, while dashboard-created tables get RLS enabled. - Prepared statements on transaction mode: stale advice. The old “transaction mode breaks prepared statements” rule is no longer true -
PREPARE/EXECUTEsucceeded on 6543 (measured). The failure mode here is the reverse: cargo-cultedprepare: falseand session-mode workarounds that cost you transaction-mode pooling benefits for no current reason. Worst case: a pooler-mode decision made on folklore, unexamined for years. Fix: re-test against your actual pooler before carrying the workaround forward.
Decision guide
Section titled “Decision guide”Reproducing
Section titled “Reproducing”Every measured claim above comes out of a disposable OpenTofu lab (supabase-lab): one directory builds the project, association, RAM accept, endpoint, PHZ, and runner; make suite then runs the TLS matrix, connect/pgbench benches, the ceiling probe, and an API-triggered restart measurement over SSM, and renders a single evidence report; make destroy tears it all down the same day. The suite is the reproducible form of this doc’s numbers - region and tier are variables, so re-running it against your own target shape is a same-afternoon job.
| Claim | Status | How it was checked |
|---|---|---|
| 5432 + 6543 through the endpoint, verify-full | tested | psql matrix via PHZ name, hostaddr split, raw-IP negative |
Cert = db.<ref>.supabase.co, 3-cert chain | tested | openssl s_client -starttls postgres on the endpoint |
| Private pooler ~1.5x public Supavisor throughput | tested | pgbench select-only, 4 clients, 15s, both paths |
| Ceiling: refusal at 200 concurrent (micro) | tested | pgbench ramp 100 -> 250 holding pg_sleep |
| Restart window 49-131s | tested | 3 API-triggered restarts, 2s psql probe over endpoint |
| Restrictions lockout, endpoint survives | tested | restrictions -> /32, re-run matrix + public-path negative |
link --skip-pooler over the endpoint | tested | CLI link + db push via PHZ |
| Prepared statements OK on transaction mode | tested | PREPARE/EXECUTE on 6543 |
| PostgREST root requires service_role | tested | anon vs root path, on- and off-VPC |
/platform association API rejects PATs | tested | owner-role PAT against POST/GET/entitlements |
| eu-central-2 excluded from PrivateLink | asserted, untested | Studio source |
Resource endpoint exposes no dns_entry | tested (AWS provider ~> 6.0) | outputs fallback + console cross-check |
| Lambda-over-endpoint path | design-only | enable_lambda scaffolded, not run |
References
Section titled “References”-
Supabase, “PrivateLink,” Supabase Docs. https://supabase.com/docs/guides/platform/privatelink ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10
-
Supabase, “Network Restrictions,” Supabase Docs. https://supabase.com/docs/guides/platform/network-restrictions ↩ ↩2 ↩3
-
Supabase, “Compute Add-ons,” Supabase Docs. https://supabase.com/docs/guides/platform/compute-add-ons ↩ ↩2 ↩3
-
Supabase, “Connecting to Postgres,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres ↩
-
Supabase, “IPv4 Address,” Supabase Docs. https://supabase.com/docs/guides/platform/ipv4-address ↩ ↩2 ↩3
-
AWS, “What is AWS Resource Access Manager?,” AWS RAM User Guide. https://docs.aws.amazon.com/ram/latest/userguide/what-is.html ↩
-
AWS, “Resource configurations in VPC Lattice,” Amazon VPC Lattice User Guide. https://docs.aws.amazon.com/vpc-lattice/latest/ug/resource-configurations.html ↩
-
Supabase, “supabase link,” Supabase CLI Reference. https://supabase.com/docs/reference/cli/supabase-link ↩ ↩2
-
Supabase, “Restart a project,” Supabase Management API Reference. https://supabase.com/docs/reference/api/v1-restart-a-project ↩