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 between 2026-07-31 and 2026-08-07 on throwaway Team-plan orgs, most of it on this rig: a micro Supabase project in ap-southeast-1, an in-VPC t3.micro runner with no public IP, reachable only through AWS Systems Manager (SSM), and an OpenTofu lab that builds and destroys the whole shape in minutes (VPC, endpoint, private hosted zone, runner, test suite). The second-VPC, service-network, per-path restart and association-removal results come from the 2026-08-07 run, which added a peered VPC and a Lattice service network to that shape. Claims about platform internals that were not re-tested are marked asserted and linked to their source. The HTTP-tier lockdown numbers (Data API and Realtime toggles) come from a second rig, 2026-08-02: a bare micro project with no VPC at all, since those levers are project configuration rather than network topology.

TL;DR:

  • 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 but not a precise number: PgBouncer queues before it refuses, and five isolated measurements put the first max_client_conn refusal at 174, 213, 213, 287 and 288 concurrent clients on micro. Size against the published tier limit, not a measured one.3
  • Restart downtime is per path, and the paths do not move together. One restart, both private paths sampled every 500ms: direct 5432 was down 45s, the pooler on 6543 60s, with a different failure mode on each. A single number for “a restart” hides which client sees what.
  • A second VPC does not need its own endpoint. A routed path alone does not carry the endpoint, but a routed path plus a private-hosted-zone association plus a security-group rule does - measured on both ports over VPC peering and over a transit gateway. Neither a second endpoint nor the 5x-dearer Lattice service network is required.
  • You cannot remove an AWS account while anything is still attached to it. The control plane refuses the removal until every consumer attachment (the endpoint, any Lattice resource association) is gone, so a PrivateLink teardown has a mandatory order.
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 organization-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 Both were measured. The service-network path works and connects on 5432 and 6543 like the endpoint does, through a hostname the association hands back in the shape snra-<id>.rcfg-<id>.<hash>.vpc-lattice-rsc.<region>.on.aws. That is not the project hostname, and the lab’s probe there ran with certificate verification disabled, so the TLS half of this path is untested: the leaf carries a single SAN of db.<ref>.supabase.co (measured), so verify-full against the Lattice name should fail on hostname mismatch exactly as it does against a raw ENI IP, and the same private-hosted-zone treatment should be needed. That follows from the measured SAN rather than having been probed.

Which produces this lifecycle. Everything to the right of the dashed box is Terraform; the dashed box is the one step that is not:

Not automatableSupabase sideYour AWS account (Terraform from here on)Dashboard:Add AWS accountAssociationCREATING -> READY1. PAT is rejectedon this routeLattice ResourceConfiguration2. createsRAM invitationACCEPT FIRST3. shares via RAMVPC endpoint(type: Resource)4. accept, then theARN becomes visibleRoute53 PHZA -> ENI IPs5. ENI IPs intoa PHZ record

Four properties of that sequence are worth understanding before building it. For the build itself, follow the OpenTofu guide, which carries the same ground as ordered steps.

  1. The association is dashboard-only, and takes about 2 minutes. Settings > Integrations > AWS PrivateLink, CREATING -> READY (measured). There is no API route that accepts a PAT, which is what makes this the one manual step in an otherwise declarative build.
  2. RAM acceptance gates ARN visibility. The share name is sspl-<ref>-<random>; the resource configuration is <org>-<ref>-rc.1 This is a hard ordering requirement rather than a convention: aws ram list-resources shows nothing while the invitation is PENDING (measured), so automation has to accept first and look up the Amazon Resource Name (ARN) second. A declarative aws_ram_resource_share_accepter deadlocks on exactly this.
  3. The endpoint needs both ports open. Type Resource, IPv4, one elastic network interface (ENI) per subnet, security group (SG) 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)8; the reliable handle is the ENI private IPs. They go in a PHZ A record for db.<ref>.supabase.co at a 60-second time to live, TTL (the zone apex cannot be a CNAME record, 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
AWS credentialsprovider-block access_key / secret_key, or empty for the ambient chainProvider-block credentials are the highest-precedence entry in the AWS chain (verified: bogus keys there beat valid environment variables), so a stale AWS_ACCESS_KEY_ID in the shell cannot fail every call with InvalidClientTokenId
Teardown coveragestate, plus whatever the orchestration created outside itState 0 after destroy still leaves the suite’s S3 bucket, which needed its own target; a VPC-attached Lambda’s ENIs also hold the subnet delete for tens of minutes

The build sequence, the two-pass mechanics, and the operational notes (credentials, plan-file secrets, teardown order) are in the companion guide.

The supabase provider9 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 subject alternative name (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 certificate authority (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;10 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 behavior - re-test before cargo-culting prepare: false everywhere.

The end state most PrivateLink adopters want is: database reachable only through the endpoint. Database network restrictions narrowed to a single break-glass /32 get you there.2 Measured, they stop both public direct (5432) and public Supavisor (6543) - the restriction covers pooled and direct routes alike2 - while leaving the endpoint path untouched, with the full connection matrix still passing in force. The OpenTofu guide has the apply and the verification.

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, Auth, Storage, Realtime

Section titled “What stays public: the Data API, Auth, Storage, Realtime”

PrivateLink covers direct Postgres and PgBouncer only; API, Auth, Storage, and Realtime keep operating over the public internet.1 Measured from inside the VPC, where traffic egresses via a network address translation (NAT) gateway:

  • 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 449ms of first-hit time to first byte, 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 row-level security (RLS); dashboard-created tables get RLS enabled. A health-check table created by migration is anonymously readable by default - decide which you want.

None of these surfaces can be moved onto PrivateLink, so the remaining question is what switching them off buys. Measured on a bare micro project, three consecutive runs, 2026-08:

SurfaceLeverWith the lever appliedExpressible in IaC
Data APIdashboard: Integrations > Data API > Enable Data API off,11 or db_schema: "" on the PostgREST config12 - measured to be the same leverPostgREST stops serving as a steady 503 PGRST002 schema-cache loop; /rest/v1/ still answers 401Yes, via db_schema - and prefer it, because the dashboard round-trip destroys the schema list
Realtimeprivate_only on the Realtime config1314Anon WebSocket still connects; the public-channel join is refusedYes (HTTP 204, ~9s to effect)
Auth, Storagenone--

Realtime enforces private_only at channel join. Flipping it to true took effect in 9s in every run; the anon handshake still completes (89ms) and phx_join on a non-private channel comes back {"status":"error","response":{"reason":"PrivateOnly: This project only allows private channels"}}. The toggle governs what a connected client may do. The endpoint stays internet-reachable and keeps answering.

The pooler queues before it refuses. Ramping concurrent clients against 6543 (transaction mode, micro), the first thing you hit is not a ceiling but a queue: at 150 clients PgBouncer accepts every connection and emits NOTICE: No server connection available in postgres backend, client being queued, and only a subset of transactions complete inside the client timeout. Refusal is a separate, later regime - FATAL: no more connections allowed (max_client_conn)15. Do not quote a precise ceiling from this. Isolated probes on a quiet system put the first refusal at the 174th, 213th, 213th, 287th and 288th concurrent client across four micro projects, against a published figure of 200.3 The fifth sample landing one client from the fourth is a coincidence of two runs, not convergence - the first three are still spread across 113 clients. Size against the published number; what is stable, and what matters for serverless concurrency, is the ordering: overshoot appears as queue latency and client timeouts first, refusals second.

Restart downtime, per path. An API-triggered restart16 with both private paths sampled independently every 500ms, recovery counted only once success was sustained: direct 5432 down 45s, pooler 6543 down 60s. They do not move together, and they do not fail the same way - 5432 answers the database system is starting up while 6543 gives timeout expired. That second mode is the one to design against: a refusal returns immediately, a timeout burns a serverless function’s whole budget per attempt, so a 30s Lambda gets one attempt where a refusing path would give it several.

Earlier runs of this document quoted 49-131s and 59-93s from a single-path probe at 2-5s resolution that took the first successful sample as recovery. Those numbers were measuring one path at a time and reporting the result as “a restart”, which is the same conflation the platform operation cost reference documents for the public paths - there, a restart takes the pooler down for 158s while REST and Realtime never fail at all. Treat any single restart figure, here or anywhere, as a per-path measurement until told otherwise. Plan around the worst path your clients actually use, and treat “restart drops the private path too” as expected: the endpoint stays up, the database behind it does not.

Endpoint replacement churns the ENI IPs. Measured: replacing the endpoint moved them from 10.42.1.93 / 10.42.2.139 to 10.42.1.203 / 10.42.2.170, and clients recovered only once the same apply refreshed the PHZ record. Replacement is also a two-pass operation - a single apply fails on the ENI data source’s for_each over not-yet-known interface IDs.

Neither is covered by the setup above, and both are the first questions asked once the primary path works. The replica half is still doc-cited; the second-VPC half was measured on 2026-08-07, and the answer is not what an earlier revision of this document asserted.

  • Read replicas each get their own dedicated database endpoint,17 so a replica is a separate target rather than something the primary’s resource configuration covers - and establishing PrivateLink for one is not self-serve: the docs say to go through your account rep.1 Not tested.
  • A second VPC does not inherit the endpoint - but it does not need one of its own either. A Resource-type endpoint is a per-VPC object, so nothing reaches a second VPC automatically. Measured: a Lambda in a second VPC, peered to the first, connected through the original endpoint on both 5432 and 6543 once three things were in place - the peering connection with routes both ways, a Route53 private-hosted-zone association for the second VPC, and a security-group rule admitting the peer CIDR. Peering alone genuinely does not carry it. What does not follow is the conclusion this document previously drew from that: that the only options are one endpoint per VPC or a Lattice service-network association.18 For a peered VPC there is a third option, and at $0.10 per resource-hour for the service network against $0.02 for endpoints, it is also the cheapest. It generalises to a transit gateway. Same second VPC, peering replaced by a gateway with an attachment per VPC and routes both ways, same PHZ association and SG rule: reachable on both ports again. That matters more than the peering case for anyone past about three VPCs, where a peering mesh stops being practical. The test attributed the transport rather than assuming it - with the peering connection torn down it confirmed zero active peerings against two available gateway attachments before trusting the result, because a stale peering connection would have carried the traffic and made a pass meaningless. One limit remains on both: the probe was a Lambda with certificate verification disabled, so reachability and PHZ resolution are measured but verify-full from the second VPC is not - it uses the same project hostname and certificate as the measured endpoint path, so the gap is small. Direct Connect stays an inference; see the evidence table.

Published us-east-1 rates, 2026-07 - rates vary by region, so treat these as shape rather than a quote.

Line itemRateNotes
Resource endpoint$0.02 per resource-hour19Per provisioned endpoint; partial hours bill as full hours
Data through the endpoint$0.01/GB first 1 PB, $0.006 next 4 PB, $0.004 beyond19Same tiers as interface endpoints
Service-network association (the other consumption path)$0.10 per resource-hour18The VPC association itself is free; the per-resource hour is 5x the endpoint path
Route53 private hosted zone$0.50/zone/monthOne zone per project hostname
NAT gateway, if the subnet needs egress~$0.045/hour + per-GBNeeded for the Management API (supabase link), not for the database path
Supabase IPv4 add-on~$4/monthMoot under PrivateLink - the endpoint is already in-VPC IPv4

Two things fall out of that table. The endpoint path costs a fifth of the service-network path per resource-hour, so “attach it to my existing Lattice service network” is a materially more expensive default than it looks. And at any real volume the hourly charges stop mattering: 1 TB/month through the endpoint is ~$10, against ~$15 of endpoint-hours.

  • Connect p50 is 31-37ms across the three reachable paths in-region - one region’s wire plus TLS. The throughput gap between private PgBouncer and public Supavisor is 1.5x; the absolute tps figures move with tier, since a t3.micro runner and micro compute bound both ends. Public-direct was not measurable at all: without the IPv4 add-on there is no A record to connect to.
  • The ceiling is a tier property, not a PrivateLink one, and it arrives as queueing before refusal.3 The endpoint added no measurable refusal below the platform limit. Five isolated measurements gave five different boundaries (174, 213, 213, 287, 288), so size against the published figure rather than a measured one.
  • Restart is a per-path event. The two private paths went down for 45s and 60s on the same restart, with different failure modes. Earlier single-path figures from this document (49-131s, 59-93s) were measuring one path at a time at 2-5s resolution and calling the result “a restart”; do not compare them with the per-path numbers, and do not average them.
  • Beta. PrivateLink is beta - not generally available (GA) - Team and Enterprise only, and the setup flow “may evolve”.1 Re-run the matrix before quoting these numbers at GA.

Grouped by when they bite. Each one below was measured, not inferred.

  • The official walkthrough’s SG opens 5432 only. The endpoint serves 6543 (PgBouncer) too, and SGs drop rather than refuse - so a verbatim walkthrough SG leaves psql ... :6543 hanging to client timeout while direct 5432 works, an asymmetry that points at Lattice rather than the one-line SG gap. Add TCP 6543 inbound from the same source; the 6543 matrix test only passed after the second rule.1
  • RAM acceptance comes before ARN lookup. list-resources returns empty while the invitation is PENDING (measured), and a declarative aws_ram_resource_share_accepter deadlocks: the phase that could apply it needs the ARNs that only acceptance produces. Orchestrate accept-then-lookup (the lab’s make arns) and gate phase 2 on the produced arns.tfvars.
  • Resource endpoints expose no API-visible DNS name. Gateway and Interface endpoints hand you dns_entry; Resource-type endpoints do not (measured, AWS provider ~> 6.0). Put the ENI IPs in a PHZ apex A record at TTL 60, refreshed by the same apply that manages the endpoint - endpoint replacement is a DNS event.
  • The Lattice association resource never converges if you hand it an ARN. aws_vpclattice_service_network_resource_association accepts an ARN for resource_configuration_identifier and returns the bare id, so every apply fails with Provider produced inconsistent result after apply and leaves a replacement association behind - which bills per resource-hour. Measured on AWS provider 6.57.1. Pass the id, not the ARN: element(split("/", var.resource_configuration_arn), 1).
  • Public direct is IPv6-only without the add-on. db.<ref>.supabase.co publishes only an AAAA (IPv6) record unless you buy IPv4 (~$4/mo), so an IPv4-only client has no public-direct path while the pooler and Data API still answer - a confusing failure shape.5 Supavisor session mode is the free IPv4 answer; under PrivateLink the endpoint already provides in-VPC IPv4 on both ports, so neither matters.
  • Default supabase link goes public. A plain link stores the shared-pooler connection, so db push works right up to the day you close public access and the migration pipeline breaks at deploy time.10 Use link --skip-pooler (direct via the PHZ) or db push --db-url, and prove it once with restrictions already closed (measured: T08 vs T09/T10).
  • “Transaction mode breaks prepared statements” is stale. PREPARE/EXECUTE succeeded on 6543 (measured), so cargo-culted prepare: false costs transaction-mode pooling for no current reason. Re-test against your actual pooler before carrying the workaround forward.
  • PostgREST’s root requires service_role. /rest/v1/ answers anon keys with 401 “Only the service_role API key can be used for this endpoint” (measured on- and off-VPC), which reads as key misconfiguration. Probe a real table with the anon key - and note SQL-created tables get anon SELECT via default privileges with no RLS, while dashboard-created tables get RLS enabled.
  • Disabling the Data API wedges PostgREST rather than removing it, whichever lever you pull. db_schema: "" and the dashboard toggle are the same thing (measured): a steady 503 PGRST002 schema-cache retry loop that also takes /graphql/v1 down while the root keeps answering 401. Monitoring sees an outage, not a clean 404. Plan the alert copy accordingly.
  • The dashboard Data API toggle destroys your schema list; the API does not. Off-then-on rewrites db_schema to the constant public (measured from public,graphql_public,pvlab_api), silently dropping every other exposed schema including graphql_public. Clients then get 406 PGRST106 "Invalid schema: ...". Use PATCH /v1/projects/{ref}/postgrest for this, which round-trips the value you give it, and record the schema list somewhere before anyone clicks that toggle.
  • private_only is enforced at channel join. The anon WebSocket handshake still completes with it enabled; only the public-channel join is refused (measured, ~9s to effect). The Realtime endpoint stays reachable from the internet, so the exposed surface is unchanged.
  • Association automation stops at the dashboard. The undocumented /platform routes reject PATs with 401 “JWT could not be decoded” - measured on the association POST, the status GET, and entitlements with an owner-role PAT; they want a dashboard session JWT. Write the association runbook as an ops procedure (one click + one RAM share per AWS account); everything downstream (RAM accept, endpoint, DNS) is AWS-side and automatable.
  • Removing an AWS account is refused while anything is still attached. The dashboard returns Cannot remove last AWS PrivateLink Association: There are still Endpoint Associations attached to the Resource Configuration (measured) until every consumer-side attachment is gone - the VPC endpoint, and any Lattice service-network resource association, both count. So teardown has a mandatory order: destroy the endpoint and any service-network association first, then remove the account. The useful consequence is that the platform will not let you strand live clients by removing an account out from under them. Deleting the whole project bypasses the check entirely, which is why a lab that always tears down by deleting the project never meets it.
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.

Does traffic get there, over what, and with TLS that verifies.

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
Resource endpoint exposes no dns_entrytested (AWS provider ~> 6.0)outputs fallback + console cross-check
Lambda in private subnets -> endpoint, 5432 and 6543tested, single sampleVPC-attached Lambda; cold connect 698ms / 218ms (one invocation - cold-start dominated, do not treat as a distribution), prepared statements OK on both
Endpoint replacement churns ENI IPstested-replace in a two-pass apply: IPs moved .93/.139 -> .203/.170; clients recovered once the same apply refreshed the PHZ record
IPv6: dualstack endpointtestedCan be CREATED dualstack in an IPv6-enabled VPC (accepted). CONVERTING an existing IPv4 endpoint is rejected - ModifyVpcEndpoint InvalidParameter. Build it dualstack from the start

One endpoint, several consumers.

ClaimStatusHow it was checked
Reaching the endpoint from a peered VPCtested, TLS not verifiedLambda in a second, peered VPC connected on 5432 and 6543 through the original endpoint, with routes both ways, a PHZ association for the second VPC, and an SG rule for the peer CIDR. A second endpoint was not needed. The probe disabled certificate verification, so verify-full from the peer VPC is untested - same hostname and certificate as the endpoint path, so the gap is small
Reaching the endpoint over Transit Gatewaytested, TLS not verifiedTransit gateway with an attachment per VPC and routes both ways, reusing the PHZ association and SG rule; a Lambda in the second VPC connected on 5432 and 6543. Transport was attributed, not assumed: 0 active peering connections against 2 available gateway attachments, checked against AWS directly, so a stale peering path could not have carried it. Certificate verification was disabled on the probe
Reaching the endpoint over Direct Connectinference only, not lab-testableNeeds a physical cross-connect at a Direct Connect location or a partner-hosted circuit, so it will not be measured on throwaway infrastructure. The peering result should generalise to any routed path, but this row stays an inference by construction rather than by backlog
Service-network consumption pathtested, TLS not verifiedService network + resource association + VPC association; connected on 5432 and 6543 via the snra-<id>...vpc-lattice-rsc name, with certificate verification disabled on the probe. Reachability measured, verify-full over that name not tested. Costs 5x the endpoint path per resource-hour

What happens under load, and during platform operations.

ClaimStatusHow it was checked
Private pooler ~1.5x public Supavisor throughputtested, single sample per projectpgbench select-only, 4 clients, 15s. 3810 / 3350 / 2258 tps on one project; 4317 / 3445 on another. Ratios held, absolutes moved
Ceiling: queues before refusing; max_client_conn boundary not reproducible (174, 213, 213, 287, 288 across five isolated probes)testedpgbench -j 8, isolated probe on a quiet system, server error text captured
Restart: direct 5432 down 45s, pooler 6543 down 60s, on the same restarttestedOne API-triggered restart, both private paths sampled independently at 500ms, recovery counted only once sustained, and skipped outright had either path been unhealthy at the first sample. Failure modes differ: the database system is starting up on 5432, timeout expired on 6543
Superseded: restart 49-131s (psql), 59-93s (Lambda on 6543)withdrawnThe six samples were 49s, 72s and 131s by select 1 at a 2s probe interval, then 93s, 59s and 89s through a Lambda at 5s - a ~3x spread read at the time as run-to-run variance. One path per run, recovery taken at the first successful probe, and no check that the path was working beforehand, so a path already down would have had its outage counted as restart downtime. Not comparable with the per-path row above; retained here so the figures stay visible as withdrawn rather than being silently re-derived
Single availability-zone (AZ) ENI failure behaviortestedOne endpoint ENI address blackholed with a network access control list (NACL) deny: node-postgres 3/3 and psql both still connected via the PHZ name. One address blackholed is not a full AZ outage, and 3/3 does not prove deterministic failover ordering
PgBouncer stability over long idle/bursty periodsout of scopeA known failure shape for serverless workloads needs a multi-day soak, not an afternoon

The tooling that has to keep working once the path is private.

ClaimStatusHow it was checked
link --skip-pooler over the endpointtestedCLI link + db push via PHZ
Prepared statements OK on transaction modetestedPREPARE/EXECUTE on 6543
Restrictions lockout, endpoint survivestestedrestrictions -> /32, re-run matrix + public-path negative

PrivateLink does not cover it; these are the levers that do.

ClaimStatusHow it was checked
PostgREST root requires service_roletestedanon vs root path, on- and off-VPC
Data API disable has no Management API leverwithdrawnThere is no enabled field, but that was the wrong field: db_schema: "" IS the lever the dashboard toggle uses. Retained so the earlier claim is visible as withdrawn. The 503 PGRST002 behavior it described (120s hold, /graphql/v1 503 with it, root still 401, restore 1-2s) still holds
The dashboard Data API toggle’s actual off-statetestedThe toggle writes db_schema: "" - same lever as the Management API, same observable state (503 PGRST002, root still 401). Confirmed by reading the config back while the toggle was off
The dashboard toggle round-trip is lossytestedFrom public,graphql_public,pvlab_api, off-then-on returned db_schema: "public" - a constant, not the prior value nor the platform default. Dropped schemas answer 406 PGRST106. Propagation measured from the click: ~2s to disable, ~8s to enable
Data API off with the private path in usetestedWith db_schema: "" wedging PostgREST, a query over the private path succeeded on 5432 and on 6543, and supabase db push --db-url still applied migrations over the endpoint. The HTTP tier and the database socket are independent
Realtime over the endpointnot applicable, testedWebSocket handshake completes from in-VPC against the API hostname (72ms) via NAT; the endpoint does not serve it
Realtime private_only enforced at channel jointested, three runsPATCH 204, ~9s to effect; handshake still completes, phx_join on a public channel returns PrivateOnly: This project only allows private channels

The parts that need a human, and the order they have to happen in.

ClaimStatusHow it was checked
/platform association API rejects PATstestedowner-role PAT against POST/GET/entitlements
Association DELETE (removing an AWS account)testedRefused while any consumer attachment remains: Cannot remove last AWS PrivateLink Association: There are still Endpoint Associations attached to the Resource Configuration. After destroying the endpoint and the service-network association it succeeded and the RAM share disappeared at once. Client impact at removal was therefore not measurable - the platform does not permit the state where it would occur
eu-central-2 excluded from PrivateLinktestedProject Settings > Integrations on a Team project in eu-central-2 lists GitHub and Vercel only - no AWS PrivateLink entry - in an org where the section renders for an ap-southeast-1 project the same day. Region is the only variable. Shows there is no self-serve path; does not prove the backend would refuse
Read replicas behind PrivateLinkdoc-cited, not testedReplicas have their own database endpoints; PrivateLink for a replica goes through an account rep, not self-serve

A caution on the numbers above: most are one sample per project. The ceiling claim in this document was wrong three times (200, then 213, then 287) precisely because a single measurement read as authoritative. Latency and throughput figures moved noticeably between projects even where the qualitative result held, so treat them as orders of magnitude and ratios, not reproducible constants.

  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. HashiCorp, “aws_vpc_endpoint,” Terraform AWS Provider Registry. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint

  8. Supabase, “supabase provider,” Terraform Registry. https://registry.terraform.io/providers/supabase/supabase/latest/docs

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

  10. Supabase, “Securing your API,” Supabase Docs. https://supabase.com/docs/guides/api/securing-your-api

  11. Supabase, “Update PostgREST service config,” Supabase Management API Reference. https://supabase.com/docs/reference/api/v1-update-postgrest-service-config 2

  12. Supabase, “Update Realtime config,” Supabase Management API Reference. https://supabase.com/docs/reference/api/v1-update-realtime-config

  13. Supabase, “Realtime Authorization,” Supabase Docs. https://supabase.com/docs/guides/realtime/authorization

  14. PgBouncer, “Configuration - max_client_conn,” pgbouncer.org. https://www.pgbouncer.org/config.html

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

  16. Supabase, “Read Replicas,” Supabase Docs. https://supabase.com/docs/guides/platform/read-replicas

  17. Amazon Web Services, “Amazon VPC Lattice pricing,” AWS. https://aws.amazon.com/vpc/lattice/pricing/ 2

  18. Amazon Web Services, “AWS PrivateLink pricing,” AWS. https://aws.amazon.com/privatelink/pricing/ 2