Skip to content

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-pooler is the load-bearing CLI flag. A default link targets the public shared pooler, which is exactly what PrivateLink designs close off. With --skip-pooler, db push runs 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.
Your VPC (same region)SupabaseApp / Lambda /migration runnerRoute53 PHZdb.<ref>.supabase.co -> ENI IPsresolveVPC endpoint(type: Resource)5432 / 6543Data API / Auth /Storage / RealtimeHTTPS (public)Shared pooler(Supavisor, public)6543 (public)Lattice ResourceConfigurationPostgres :5432PgBouncer :6543(transaction mode)

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.

PathReachable viaMeasured p50 connectMeasured pgbench (4 clients)Pick when
Private 5432 (direct)Endpoint37ms3810 tps @ 1.05msMigrations, pg_dump, single sessions, anything wanting session state
Private 6543 (PgBouncer)Endpoint31ms3350 tps @ 1.19msApp servers in the VPC; highest concurrent-client count on the private path
Public Supavisor 6543Internet31ms2258 tps @ 1.77msIPv4-only networks with no PrivateLink; free on every tier4
Public direct 5432Internetn/a from an IPv4-only VPCn/aIPv6-capable networks, or with the IPv4 add-on5
Data API (HTTPS)Internet onlyp50 18.6ms warmn/aPostgREST/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.

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:

  1. Add the AWS account in the dashboard (Settings > Integrations > AWS PrivateLink). Status goes CREATING -> READY in about 2 minutes.
  2. 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-resources shows nothing while the invitation is PENDING (measured). Any automation must accept first, then look up the ARN - a declarative aws_ram_resource_share_accepter deadlocks on this.
  3. 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.
  4. DNS is on you. A Resource-type endpoint does not expose a usable dns_entry via the AWS API (measured on provider ~> 6.0); the reliable handle is the ENI private IPs. Put them in a PHZ A record for db.<ref>.supabase.co at TTL 60 (the zone apex cannot be a CNAME, so the apex carries the IPs directly).

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.

LayerResource / mechanismNotes from the lab
Project + settingssupabase_project, supabase_settings (provider ~> 1.10)Network restrictions go in as network = jsonencode({ restrictions = [...] }) - shape verified, applies clean, survives toggling
PrivateLink associationnone - no provider resource, /platform rejects PATsDashboard click per AWS account; a gated restapi_object exists in the lab but stays off by default
RAM shareCLI, not declarativeaws_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
Endpointaws_vpc_endpoint with vpc_endpoint_type = "Resource" + resource_configuration_arnNo dns_entry output (provider ~> 6.0); SG with both 5432 and 6543
DNSaws_route53_zone + apex A record from data.aws_network_interface ENI IPsTwo-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-full against the PHZ name: PASS.
  • sslmode=verify-full with host=<PHZ name> hostaddr=<ENI IP> (no DNS dependency): PASS.
  • sslmode=verify-full against the raw endpoint IP: fails by design (hostname mismatch). There is no IP SAN; an IP-based connection string can never do better than verify-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.

Measured with the Supabase CLI on the in-VPC runner:

FlowResultPath taken
supabase link + db push (defaults)works, but wrong pathdefault link targets the public shared pooler - useless in a locked-down design
supabase link --skip-pooler + db pushworks over the endpointdirect connection via PHZ
supabase db push --db-url postgres://...@db.<ref>.supabase.co:5432/...works over the endpointno 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.

The end state most PrivateLink adopters want is: database reachable only through the endpoint. Measured sequence:

  1. Apply DB network restrictions narrowed to a single break-glass /32.2
  2. Public direct (5432) and public Supavisor (6543) both stop connecting - restrictions cover pooled and direct routes.2
  3. 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”.

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 the service_role API 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 anon SELECT 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.

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.

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

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 ... :5432 works, psql ... :6543 hangs 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 /platform routes 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 /v1 call succeeds with the same token while every /platform call 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 /v1 spec 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-resources returns 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 declarative aws_ram_resource_share_accepter deadlocks, 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’s make arns), with the produced arns.tfvars gating 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 assume dns_entry stall, 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.co publishes 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” while psql direct 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 link goes public. A plain supabase link stores the shared-pooler (public Supavisor) connection.8 The failure mode: db push works 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) or db push --db-url, and prove it by running the migration once with restrictions already closed. Measured: default link succeeded via public Supavisor (T08); --skip-pooler and --db-url both 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 the service_role API 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 gets anon SELECT 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/EXECUTE succeeded on 6543 (measured). The failure mode here is the reverse: cargo-culted prepare: false and 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.
DB clients in your AWS VPC?PrivateLink endpoint5432 direct / 6543 pooledyes (Team/Ent)Public pathsno / other cloudsData API only?Stay on HTTPS - no PrivateLink path existsno DB socket neededIPv6 or IPv4 add-on:direct 5432need directSupavisor 6543(IPv4, free)IPv4-only

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.

ClaimStatusHow it was checked
5432 + 6543 through the endpoint, verify-fulltestedpsql matrix via PHZ name, hostaddr split, raw-IP negative
Cert = db.<ref>.supabase.co, 3-cert chaintestedopenssl s_client -starttls postgres on the endpoint
Private pooler ~1.5x public Supavisor throughputtestedpgbench select-only, 4 clients, 15s, both paths
Ceiling: refusal at 200 concurrent (micro)testedpgbench ramp 100 -> 250 holding pg_sleep
Restart window 49-131stested3 API-triggered restarts, 2s psql probe over endpoint
Restrictions lockout, endpoint survivestestedrestrictions -> /32, re-run matrix + public-path negative
link --skip-pooler over the endpointtestedCLI link + db push via PHZ
Prepared statements OK on transaction modetestedPREPARE/EXECUTE on 6543
PostgREST root requires service_roletestedanon vs root path, on- and off-VPC
/platform association API rejects PATstestedowner-role PAT against POST/GET/entitlements
eu-central-2 excluded from PrivateLinkasserted, untestedStudio source
Resource endpoint exposes no dns_entrytested (AWS provider ~> 6.0)outputs fallback + console cross-check
Lambda-over-endpoint pathdesign-onlyenable_lambda scaffolded, not run
  1. Supabase, “Network Restrictions,” Supabase Docs. https://supabase.com/docs/guides/platform/network-restrictions 2 3

  2. Supabase, “Compute Add-ons,” Supabase Docs. https://supabase.com/docs/guides/platform/compute-add-ons 2 3

  3. Supabase, “Connecting to Postgres,” Supabase Docs. https://supabase.com/docs/guides/database/connecting-to-postgres

  4. Supabase, “IPv4 Address,” Supabase Docs. https://supabase.com/docs/guides/platform/ipv4-address 2 3

  5. AWS, “What is AWS Resource Access Manager?,” AWS RAM User Guide. https://docs.aws.amazon.com/ram/latest/userguide/what-is.html

  6. AWS, “Resource configurations in VPC Lattice,” Amazon VPC Lattice User Guide. https://docs.aws.amazon.com/vpc-lattice/latest/ug/resource-configurations.html

  7. Supabase, “supabase link,” Supabase CLI Reference. https://supabase.com/docs/reference/cli/supabase-link 2

  8. Supabase, “Restart a project,” Supabase Management API Reference. https://supabase.com/docs/reference/api/v1-restart-a-project