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-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 but not a precise number: PgBouncer queues before it refuses, and five isolated measurements put the first
max_client_connrefusal 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.
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 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:
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.
- 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.
- 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-resourcesshows nothing while the invitation is PENDING (measured), so automation has to accept first and look up the Amazon Resource Name (ARN) second. A declarativeaws_ram_resource_share_accepterdeadlocks on exactly this. - 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. - DNS is on you. A Resource-type endpoint does not expose a usable
dns_entryvia the AWS API (measured on provider ~> 6.0)8; the reliable handle is the ENI private IPs. They go in a PHZ A record fordb.<ref>.supabase.coat a 60-second time to live, TTL (the zone apex cannot be a CNAME record, 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 |
| AWS credentials | provider-block access_key / secret_key, or empty for the ambient chain | Provider-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 coverage | state, plus whatever the orchestration created outside it | State 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-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 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.
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;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.
Closing public access
Section titled “Closing public access”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 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 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.
What the off switches do
Section titled “What the off switches do”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:
| Surface | Lever | With the lever applied | Expressible in IaC |
|---|---|---|---|
| Data API | dashboard: Integrations > Data API > Enable Data API off,11 or db_schema: "" on the PostgREST config12 - measured to be the same lever | PostgREST stops serving as a steady 503 PGRST002 schema-cache loop; /rest/v1/ still answers 401 | Yes, via db_schema - and prefer it, because the dashboard round-trip destroys the schema list |
| Realtime | private_only on the Realtime config1314 | Anon WebSocket still connects; the public-channel join is refused | Yes (HTTP 204, ~9s to effect) |
| Auth, Storage | none | - | - |
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.
Capacity and failure behavior
Section titled “Capacity and failure behavior”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.
Read replicas and more than one VPC
Section titled “Read replicas and more than one VPC”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-fullfrom 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 item | Rate | Notes |
|---|---|---|
| Resource endpoint | $0.02 per resource-hour19 | Per 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 beyond19 | Same tiers as interface endpoints |
| Service-network association (the other consumption path) | $0.10 per resource-hour18 | The VPC association itself is free; the per-resource hour is 5x the endpoint path |
| Route53 private hosted zone | $0.50/zone/month | One zone per project hostname |
| NAT gateway, if the subnet needs egress | ~$0.045/hour + per-GB | Needed for the Management API (supabase link), not for the database path |
| Supabase IPv4 add-on | ~$4/month | Moot 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.
Reading the numbers
Section titled “Reading the numbers”- 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.
Gotchas
Section titled “Gotchas”Grouped by when they bite. Each one below was measured, not inferred.
Standing it up
Section titled “Standing it up”- 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 ... :6543hanging 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-resourcesreturns empty while the invitation is PENDING (measured), and a declarativeaws_ram_resource_share_accepterdeadlocks: the phase that could apply it needs the ARNs that only acceptance produces. Orchestrate accept-then-lookup (the lab’smake arns) and gate phase 2 on the producedarns.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_associationaccepts an ARN forresource_configuration_identifierand returns the bare id, so every apply fails withProvider produced inconsistent result after applyand 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).
Connecting to it
Section titled “Connecting to it”- Public direct is IPv6-only without the add-on.
db.<ref>.supabase.copublishes 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 linkgoes public. A plain link stores the shared-pooler connection, sodb pushworks right up to the day you close public access and the migration pipeline breaks at deploy time.10 Uselink --skip-pooler(direct via the PHZ) ordb push --db-url, and prove it once with restrictions already closed (measured: T08 vs T09/T10). - “Transaction mode breaks prepared statements” is stale.
PREPARE/EXECUTEsucceeded on 6543 (measured), so cargo-cultedprepare: falsecosts transaction-mode pooling for no current reason. Re-test against your actual pooler before carrying the workaround forward.
The HTTP tier
Section titled “The HTTP tier”- PostgREST’s root requires service_role.
/rest/v1/answers anon keys with 401 “Only theservice_roleAPI 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 getanonSELECT 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 steady503 PGRST002schema-cache retry loop that also takes/graphql/v1down 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_schemato the constantpublic(measured frompublic,graphql_public,pvlab_api), silently dropping every other exposed schema includinggraphql_public. Clients then get406 PGRST106 "Invalid schema: ...". UsePATCH /v1/projects/{ref}/postgrestfor this, which round-trips the value you give it, and record the schema list somewhere before anyone clicks that toggle. private_onlyis 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 lifecycle and teardown
Section titled “Association lifecycle and teardown”- Association automation stops at the dashboard. The undocumented
/platformroutes 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.
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.
The network path
Section titled “The network path”Does traffic get there, over what, and with TLS that verifies.
| 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 |
Resource endpoint exposes no dns_entry | tested (AWS provider ~> 6.0) | outputs fallback + console cross-check |
| Lambda in private subnets -> endpoint, 5432 and 6543 | tested, single sample | VPC-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 IPs | tested | -replace in a two-pass apply: IPs moved .93/.139 -> .203/.170; clients recovered once the same apply refreshed the PHZ record |
| IPv6: dualstack endpoint | tested | Can be CREATED dualstack in an IPv6-enabled VPC (accepted). CONVERTING an existing IPv4 endpoint is rejected - ModifyVpcEndpoint InvalidParameter. Build it dualstack from the start |
Reaching it from elsewhere
Section titled “Reaching it from elsewhere”One endpoint, several consumers.
| Claim | Status | How it was checked |
|---|---|---|
| Reaching the endpoint from a peered VPC | tested, TLS not verified | Lambda 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 Gateway | tested, TLS not verified | Transit 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 Connect | inference only, not lab-testable | Needs 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 path | tested, TLS not verified | Service 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 |
Capacity and failure
Section titled “Capacity and failure”What happens under load, and during platform operations.
| Claim | Status | How it was checked |
|---|---|---|
| Private pooler ~1.5x public Supavisor throughput | tested, single sample per project | pgbench 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) | tested | pgbench -j 8, isolated probe on a quiet system, server error text captured |
| Restart: direct 5432 down 45s, pooler 6543 down 60s, on the same restart | tested | One 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) | withdrawn | The 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 behavior | tested | One 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 periods | out of scope | A known failure shape for serverless workloads needs a multi-day soak, not an afternoon |
Clients and migrations
Section titled “Clients and migrations”The tooling that has to keep working once the path is private.
| Claim | Status | How it was checked |
|---|---|---|
link --skip-pooler over the endpoint | tested | CLI link + db push via PHZ |
| Prepared statements OK on transaction mode | tested | PREPARE/EXECUTE on 6543 |
| Restrictions lockout, endpoint survives | tested | restrictions -> /32, re-run matrix + public-path negative |
The HTTP tier
Section titled “The HTTP tier”PrivateLink does not cover it; these are the levers that do.
| Claim | Status | How it was checked |
|---|---|---|
| PostgREST root requires service_role | tested | anon vs root path, on- and off-VPC |
| Data API disable has no Management API lever | withdrawn | There 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-state | tested | The 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 lossy | tested | From 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 use | tested | With 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 endpoint | not applicable, tested | WebSocket handshake completes from in-VPC against the API hostname (72ms) via NAT; the endpoint does not serve it |
Realtime private_only enforced at channel join | tested, three runs | PATCH 204, ~9s to effect; handshake still completes, phx_join on a public channel returns PrivateOnly: This project only allows private channels |
Association lifecycle
Section titled “Association lifecycle”The parts that need a human, and the order they have to happen in.
| Claim | Status | How it was checked |
|---|---|---|
/platform association API rejects PATs | tested | owner-role PAT against POST/GET/entitlements |
| Association DELETE (removing an AWS account) | tested | Refused 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 PrivateLink | tested | Project 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 PrivateLink | doc-cited, not tested | Replicas 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.
References
Section titled “References”-
Supabase, “PrivateLink,” Supabase Docs. https://supabase.com/docs/guides/platform/privatelink ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11
-
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 ↩
-
HashiCorp, “aws_vpc_endpoint,” Terraform AWS Provider Registry. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint ↩
-
Supabase, “supabase provider,” Terraform Registry. https://registry.terraform.io/providers/supabase/supabase/latest/docs ↩
-
Supabase, “supabase link,” Supabase CLI Reference. https://supabase.com/docs/reference/cli/supabase-link ↩ ↩2
-
Supabase, “Securing your API,” Supabase Docs. https://supabase.com/docs/guides/api/securing-your-api ↩
-
Supabase, “Update PostgREST service config,” Supabase Management API Reference. https://supabase.com/docs/reference/api/v1-update-postgrest-service-config ↩ ↩2
-
Supabase, “Update Realtime config,” Supabase Management API Reference. https://supabase.com/docs/reference/api/v1-update-realtime-config ↩
-
Supabase, “Realtime Authorization,” Supabase Docs. https://supabase.com/docs/guides/realtime/authorization ↩
-
PgBouncer, “Configuration - max_client_conn,” pgbouncer.org. https://www.pgbouncer.org/config.html ↩
-
Supabase, “Restart a project,” Supabase Management API Reference. https://supabase.com/docs/reference/api/v1-restart-a-project ↩
-
Supabase, “Read Replicas,” Supabase Docs. https://supabase.com/docs/guides/platform/read-replicas ↩
-
Amazon Web Services, “Amazon VPC Lattice pricing,” AWS. https://aws.amazon.com/vpc/lattice/pricing/ ↩ ↩2
-
Amazon Web Services, “AWS PrivateLink pricing,” AWS. https://aws.amazon.com/privatelink/pricing/ ↩ ↩2