Skip to content

Set up AWS PrivateLink to Supabase with OpenTofu

Build a private network path from an AWS VPC to a Supabase project: a VPC Lattice Resource endpoint serving direct Postgres (5432) and the project pooler (6543), TLS verify-full via a private hosted zone, Supabase CLI migrations over the endpoint, and public database access closed at the end. The measured behavior behind each step - latency, ceilings, restart windows, the tested-vs-asserted split - lives in the companion reference; this is the build sequence.

Prerequisites: a Team or Enterprise Supabase org; an AWS account in the same region as the project; a Supabase personal access token (PAT); OpenTofu >= 1.6; the AWS CLI; a VPC with private subnets (the lab builds its own). PrivateLink is same-region only, it is beta rather than generally available, and not every region is eligible - eu-central-2 is excluded.1 Confirm your region before building anything around it.

The working repo this guide is extracted from is supabase-lab - every step below ran green there on 2026-07-31 before being written down, and later runs through 2026-08-02 added the Lambda path, the typed test harness, and the IPv6 answer.

Supabase creates a VPC Lattice Resource Configuration2 for the project and shares it to your AWS account over AWS Resource Access Manager (RAM)3. You accept the share, put a Resource-type virtual private cloud (VPC) endpoint in front of it, and point the project’s real hostname at the endpoint’s elastic network interface (ENI) addresses inside a Route53 private hosted zone (PHZ) - so clients keep their normal connection string and verify-full still matches the certificate.

Your AWS account / VPCSupabase accountClient(Lambda, runner, app)Route53 PHZdb.<ref>.supabase.coA -> ENI IPs, TTL 60resolveVPC endpointtype=ResourceENI per subnetENI IPsLattice ResourceConfigurationprivateSecurity groupinbound 5432 + 6543Postgres 5432PgBouncer 6543RAM share(accept first)shared via

The Data API, Auth, Storage, and Realtime are HTTP services on a different hostname and are not carried by this path - they stay public by design.1 PrivateLink covers the database socket only.

What the run below was executed with. Provider majors are pinned in providers.tf; the lock file is committed.

ComponentVersion
OpenTofu1.12.1 (config requires >= 1.6, < 2.0)
hashicorp/aws6.57.1 (~> 6.0)
supabase/supabase1.10.1 (~> 1.10)
mastercard/restapi3.0.0 (association resource, gated off)
hashicorp/archive2.8.0 (Lambda probe zip)
Runner machine image (AMI)Amazon Linux 2023 (al2023-ami-2023.*-x86_64, latest at apply)
Runner toolingpsql 16.14, Supabase CLI 2.110.0
Region / computeap-southeast-1, micro

Fix these before any steps; every later command depends on them.

FactValue used in the labYours
Supabase region == AWS regionap-southeast-1same on both sides
Compute sizemicroany; only the pooler ceiling changes
Project hostnamedb.<ref>.supabase.co<ref> = project ref from step 1
Ports through the endpoint5432 (direct) + 6543 (pooler)both, always
Pooler user format (public Supavisor only)postgres.<ref>not used on the endpoint
Endpoint userpostgresno .<ref> suffix on the private path

Step 1: project and settings via the supabase provider

Section titled “Step 1: project and settings via the supabase provider”

The supabase provider (~> 1.10) covers the whole project control plane over /v1, authenticated with a PAT4 - including network restrictions5, which you want as code from day one:

resource "supabase_project" "lab" {
organization_id = var.supabase_org_id
name = "lab-privatelink"
database_password = var.db_password
region = var.aws_region
instance_size = "micro"
}
resource "supabase_settings" "lab" {
project_ref = supabase_project.lab.id
network = jsonencode({
restrictions = ["0.0.0.0/0", "::/0"] # open for now; step 7 closes it
})
}

Apply and record the project ref (tofu output the supabase_project.lab.id). There is no privatelink resource in the provider - do not go looking for it.

Step 2: the association - the one dashboard click

Section titled “Step 2: the association - the one dashboard click”

Adding your AWS account to the project is what makes Supabase create the VPC Lattice Resource Configuration and send the RAM share. It is also the only step that is not automatable: the undocumented /platform routes it uses reject PATs categorically (401 “JSON Web Token could not be decoded”, even owner-role tokens) - they want a dashboard session JWT.

  1. Project > Settings > Integrations > AWS PrivateLink (project-level page; nothing appears under organization settings).
  2. Add Account, enter your AWS account identifier and a description.
  3. Wait for status CREATING -> READY (~2 minutes measured).

A restapi_object for this exists in the lab behind var.send_association = false, kept as documentation of the endpoint shape; it cannot authenticate with a PAT.

Step 3: accept the RAM share, then look up the ARNs

Section titled “Step 3: accept the RAM share, then look up the ARNs”

The share arrives as sspl-<ref>-<random> containing one vpc-lattice:ResourceConfiguration named <org>-<ref>-rc. Acceptance is a hard ordering requirement - the resource configuration is invisible to list-resources while the invitation is PENDING - so a declarative aws_ram_resource_share_accepter cannot work (it needs the ARNs it is supposed to produce). This step is CLI orchestration:

Terminal window
REGION=ap-southeast-1
# find + accept the invitation for THIS project (idempotent)
INV=$(aws ram get-resource-share-invitations --region $REGION --no-paginate \
--query "resourceShareInvitations[?status=='PENDING' && resourceShareName && contains(resourceShareName, '<ref>')].resourceShareInvitationArn | [0]" --output text)
[ "$INV" != "None" ] && aws ram accept-resource-share-invitation \
--region $REGION --resource-share-invitation-arn "$INV" --output text
# only NOW does the resource configuration exist for you
SHARE=$(aws ram get-resource-share-invitations --region $REGION --no-paginate \
--query "resourceShareInvitations[?resourceShareName && contains(resourceShareName, '<ref>')].resourceShareArn | [0]" --output text)
aws ram list-resources --resource-owner OTHER-ACCOUNTS \
--resource-share-arn "$SHARE" --region $REGION --no-paginate \
--query 'resources[0].arn' --output text # -> resource_configuration_arn

Write both ARNs into an arns.tfvars; its presence gates phase 2. The lab’s make arns does exactly this, including the PENDING-accept, a --no-paginate (the RAM API returns a phantom trailing page), and a short poll after acceptance - the resource configuration is not visible to list-resources for a few seconds after the invitation flips to ACCEPTED, so accept-then-immediately-look-up still races.

Step 4: endpoint + security group (two-pass apply)

Section titled “Step 4: endpoint + security group (two-pass apply)”

Two rules that bite if skipped: the security group (SG) needs both 5432 and 6543 inbound1 (the official walkthrough shows 5432 only; with a verbatim SG the pooler path drops silently - direct works, pooler times out), and the apply is two-pass because the ENI data source keys off the endpoint’s network interface IDs, which are unknown at plan time (for_each over unknown keys = plan error).

resource "aws_security_group" "endpoint" {
vpc_id = aws_vpc.lab.id
ingress {
description = "Postgres direct"
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = [aws_vpc.lab.cidr_block]
}
ingress {
description = "PgBouncer"
from_port = 6543
to_port = 6543
protocol = "tcp"
cidr_blocks = [aws_vpc.lab.cidr_block]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_vpc_endpoint" "supabase" {
vpc_id = aws_vpc.lab.id
vpc_endpoint_type = "Resource"
resource_configuration_arn = var.resource_configuration_arn
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.endpoint.id]
ip_address_type = "ipv4"
}

Apply with -target=aws_vpc_endpoint.supabase first, then apply the rest. Targeting is a debugging tool everywhere else; here it is the only way past the unknown-IDs plan error.

Step 5: private hosted zone for verify-full

Section titled “Step 5: private hosted zone for verify-full”

A Resource-type endpoint exposes no usable dns_entry (provider ~> 6.0)6, and the endpoint certificate names exactly db.<ref>.supabase.co - so the DNS handle is the ENI private IPs, published inside the VPC under the real project hostname:

data "aws_network_interface" "endpoint" {
for_each = toset(aws_vpc_endpoint.supabase.network_interface_ids)
id = each.key
}
resource "aws_route53_zone" "db" {
name = "db.${supabase_project.lab.id}.supabase.co"
vpc { vpc_id = aws_vpc.lab.id }
}
resource "aws_route53_record" "db" {
zone_id = aws_route53_zone.db.zone_id
name = "db.${supabase_project.lab.id}.supabase.co"
type = "A"
ttl = 60 # apex cannot be a CNAME; carry the ENI IPs directly
records = [for eni in data.aws_network_interface.endpoint : eni.private_ip]
}

Clients keep their normal connection string; only the DNS answer changes.

From any host in the VPC (the lab uses a t3.micro reachable only through AWS Systems Manager, SSM):

Terminal window
# extract the presented chain once (STARTTLS); 3 certs expected
openssl s_client -starttls postgres -connect db.<ref>.supabase.co:5432 \
-showcerts </dev/null 2>/dev/null | awk '/BEGIN CERT/,/END CERT/' > ca.crt
# verify-full via the PHZ name: PASS
psql "host=db.<ref>.supabase.co port=5432 user=postgres dbname=postgres \
sslmode=verify-full sslrootcert=ca.crt" -c 'select version();'
# pooler through the endpoint (proves the second SG rule): PASS
psql "host=db.<ref>.supabase.co port=6543 user=postgres dbname=postgres \
sslmode=require" -c 'select 1;'
# negative control: verify-full against a raw ENI IP must FAIL (no IP SAN)

If 5432 passes and 6543 times out, that asymmetry is the missing SG rule from step 4, not a platform problem.

Narrow the restrictions to a break-glass /32 - public direct and public Supavisor both stop connecting while the endpoint keeps serving (restrictions cover pooled and direct routes):

Terminal window
tofu plan -var 'public_access_cidrs=["x.x.x.x/32"]' -out=tfplan # network = jsonencode({ restrictions = ... })
tofu apply tfplan

Plan to a file and apply the file, so what lands is what you read - and never commit that file (see below).

Verify from outside the VPC: psql to db.<ref>.supabase.co and to aws-<N>-<region>.pooler.supabase.com both refuse; inside the VPC, step 6 still passes. Note the scope: this locks the database socket. The Data API (PostgREST/Auth/Storage) is HTTP and stays public by design - PrivateLink does not cover it. Switching those surfaces off has its own limits: the Data API toggle and PostgREST’s db_schema are the same lever, and either way the service wedges into a steady 503 retry loop rather than disappearing cleanly - so script it with PATCH .../postgrest and never round-trip the dashboard toggle, which comes back with db_schema reset to public and every other exposed schema silently dropped. See what the off switches do for the measurements. And if the requirement is public ingress removed rather than refused, that remains a support-ticket action; what it does beyond socket refusal is untested here.

The default supabase link targets the public shared pooler - broken by design the moment step 7 lands. Both correct paths:

Terminal window
supabase link --project-ref <ref> --skip-pooler # then: supabase db push
supabase db push --db-url "postgres://postgres:<pw>@db.<ref>.supabase.co:5432/postgres"

Running it: credentials, state, and teardown

Section titled “Running it: credentials, state, and teardown”

Put the AWS credentials in the provider block, or deliberately leave them empty. Provider-block credentials are the highest-precedence entry in the AWS chain - verified by putting bogus keys in the block and watching them beat valid environment variables. A stale AWS_ACCESS_KEY_ID inherited from the shell otherwise fails every call with InvalidClientTokenId. Source them from the encrypted tfvars and export the same two values for any aws CLI orchestration in your Makefile, so both halves agree on one source of truth. Empty values fall through to the ambient chain (profile / SSO / env) and also neutralize an inherited stale pair.

Never commit a plan file. tfplan is a zip archive embedding tfstate, which includes every variable value - so a committed plan publishes the database password and the PAT regardless of how carefully the tfvars are encrypted. Gitignore tfplan, *.tfplan, tfplan-*. The same reasoning covers test-evidence directories: they capture hostnames, ENI addresses, and project refs.

Secrets that reach an in-VPC runner should not travel in the command payload. The lab passes the database password and PAT inside an SSM send-command payload, which is fine for a same-day throwaway and wrong for a real environment: SSM retains command parameters for about 30 days, readable via aws ssm list-commands and CloudTrail. Put them in Parameter Store SecureString or Secrets Manager and read them with the instance role instead.

Out-of-band edits get reverted by the next apply. Setting a Lambda environment variable by CLI, outside the config, survived exactly until the next full apply, which silently restored the config’s version and left the probe authenticating with an empty password. If a value has to exist at runtime, it belongs in the configuration.

Teardown is slower than it looks when a VPC-attached Lambda is involved. Lambda’s ENIs sit in available for tens of minutes after the function is gone, and tofu just retries the subnet delete meanwhile - 21 minutes and still blocked, in one measured teardown. Deleting the detached ENIs directly unblocks it immediately. Manage the function’s CloudWatch log group as a resource too, or destroy leaves it behind.

Anything the orchestration creates outside tofu will survive destroy. In the lab that is the S3 bucket the test suite stages its binary and artifacts through: make destroy reports state 0 while the bucket is still there, which is why there is a separate make suite-clean. Check for these before calling a teardown verified.

The lab’s make suite is the full verification battery, runnable against any deployment of this guide: a typed test harness that ships as one binary to the in-VPC runner and also runs orchestrator-side tests. It covers the TLS matrix (verify-full/verify-ca/host-hostaddr split/raw-IP negative), 30 cold connects and pgbench per path, a pooler ceiling probe that distinguishes queueing from max_client_conn refusal, a Data API probe (anon key against a real table - PostgREST’s root requires service_role), Realtime over a real WebSocket client, the CLI migration paths, a Lambda probe on both ports, and destructive tests behind --destructive (an API-triggered restart sampled per private path at 500ms, endpoint replacement, single-ENI failure, and the HTTP-tier config flips).

Two conventions in the harness carry over to any suite that measures someone else’s platform. A test is one file declaring where it runs and which capabilities it needs, so it self-skips with a reason when the infrastructure is absent - which is how the Data API and Realtime tests got answered on a bare project with no VPC at all. And every fault-injection test has a baseline gate: if the control path does not work before the fault is injected, the test returns skip and asserts nothing. Two tests here once reported the exact opposite of the truth because their baseline had silently failed and the post-fault failure looked like a result. It renders a single markdown evidence report; see the reference for the numbers and for which of them are reproducible.

Each with the symptom you will actually see. The reference keeps its own list, overlapping but not identical: it carries the measured evidence inline and adds the HTTP-tier entries, while the build-sequence entries below appear only here.

  • SG 5432-only walkthrough. The guide’s SG step opens 5432 only, but the endpoint also serves PgBouncer on 6543. SGs drop rather than refuse, so the symptom is psql :5432 works while psql :6543 hangs to timeout - which looks like a Lattice/platform problem, not a one-line SG gap. Worst case: the app silently falls back to the public pooler and your “private” deployment egresses DB traffic for weeks. Add 6543 inbound, same source as 5432.
  • RAM accept before Amazon Resource Name (ARN) lookup. A PENDING invitation hides the resource configuration from list-resources, so lookup-first automation sees empty results or races. A declarative aws_ram_resource_share_accepter deadlocks structurally: the phase that could apply it needs the ARNs that only acceptance produces. Orchestrate accept-then-lookup instead.
  • Two-pass apply. The ENI data source keys off the endpoint’s network interface IDs - unknown at plan time, and for_each over unknown keys is a plan error. Apply the endpoint first, then everything else. Skipping the target means a plan failure, not a slow apply.
  • No dns_entry on Resource endpoints. The attribute comes back empty (provider ~> 6.0). PHZ apex A record with the ENI addresses at a 60-second time to live (TTL). Worst case to design for: endpoint replacement changes the ENI IPs - treat replacement as a DNS event or every private client times out at once.
  • Association is a dashboard click. /platform routes 401 PATs with “JSON Web Token (JWT) could not be decoded” while the same token works on /v1 - reads as a token problem, is actually a credential-type problem (session JWT vs PAT). Assume the click is permanent: N accounts = N clicks, so write it as an ops procedure. Project Settings > Integrations, not org settings - looking one level up is how “the UI is missing” reports happen.
  • Public direct is IPv6-only. Without the add-on there is no public A record at all; the confusing symptom is “pooler and API work, direct psql cannot resolve/hangs”. Moot under PrivateLink - the endpoint provides in-VPC IPv4 on both ports.
  • supabase link defaults to public.7 Migrations work all through development, then break in continuous integration (CI) the day you close public access - with an error pointing at the pooler, not your link config. Use --skip-pooler or --db-url, and prove it by running one migration with restrictions already closed.
  • Prepared statements work on transaction mode now (measured). The stale advice costs you in reverse: prepare: false workarounds and session-mode detours for no current reason. Re-test before carrying them forward.
  • The pooler queues before it refuses. Below the client ceiling you do not get errors, you get NOTICE: No server connection available in postgres backend, client being queued and transactions that miss their timeout. Refusal (FATAL: no more connections allowed (max_client_conn)) is a later regime, and the boundary is not a number you can carry: five isolated probes on quiet systems put the first refusal at 174, 213, 213, 287 and 288 concurrent clients on micro, against a published figure of 200. Size against the published limit, and design for the queueing symptom rather than the refusal.
  • pgbench is not in the postgresql16 package. It ships in postgresql16-contrib; without it the benchmark phases fail with exit 127 and a suite that greps for numbers will happily record zeros. Install contrib in the image, not by hand on a live runner.
  • Endpoint replacement is a two-pass apply too, for the same for_each-over-unknown reason as step 4 - and it churns the ENI IPs, so the PHZ record must be refreshed by the same run or every private client is pointed at dead addresses.
Path in supabase-labRole
experiments/privatelink-aws/supabase.tfstep 1 + the gated restapi_object (step 2)
experiments/privatelink-aws/lattice.tfSG, endpoint, PHZ (steps 4-5)
experiments/privatelink-aws/vpc.tf / runner.tfVPC, network address translation (NAT) gateway, SSM-only runner
experiments/privatelink-aws/lambda.tfthe VPC-attached Lambda probe (enable_lambda), incl. its log group so destroy is complete
experiments/privatelink-aws/Makefilephase1 arns phase2 suite restrict destroy suite-clean orchestration
experiments/privatelink-aws/suite.shships the compiled harness to the runner over SSM, pulls artifacts from S3, renders the report
harness/the typed test harness: test contract, planner, runner, report renderer (shared, experiment-agnostic)
experiments/privatelink-aws/tests/the test modules - one file per test; the build generates the registry
experiments/privatelink-aws/RUNLOG.mdper-run findings incl. measured numbers and errata
  1. AWS, “Resource configurations in VPC Lattice,” Amazon VPC Lattice User Guide. https://docs.aws.amazon.com/vpc-lattice/latest/ug/resource-configuration.html

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

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

  4. Supabase, “Network Restrictions,” Supabase Docs. https://supabase.com/docs/guides/platform/network-restrictions

  5. HashiCorp, “aws_vpc_endpoint,” Terraform AWS Provider Registry. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint

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