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.
Architecture
Section titled “Architecture”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.
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.
Component versions
Section titled “Component versions”What the run below was executed with. Provider majors are pinned in providers.tf; the lock file is committed.
| Component | Version |
|---|---|
| OpenTofu | 1.12.1 (config requires >= 1.6, < 2.0) |
| hashicorp/aws | 6.57.1 (~> 6.0) |
| supabase/supabase | 1.10.1 (~> 1.10) |
| mastercard/restapi | 3.0.0 (association resource, gated off) |
| hashicorp/archive | 2.8.0 (Lambda probe zip) |
| Runner machine image (AMI) | Amazon Linux 2023 (al2023-ami-2023.*-x86_64, latest at apply) |
| Runner tooling | psql 16.14, Supabase CLI 2.110.0 |
| Region / compute | ap-southeast-1, micro |
Constants
Section titled “Constants”Fix these before any steps; every later command depends on them.
| Fact | Value used in the lab | Yours |
|---|---|---|
| Supabase region == AWS region | ap-southeast-1 | same on both sides |
| Compute size | micro | any; only the pooler ceiling changes |
| Project hostname | db.<ref>.supabase.co | <ref> = project ref from step 1 |
| Ports through the endpoint | 5432 (direct) + 6543 (pooler) | both, always |
| Pooler user format (public Supavisor only) | postgres.<ref> | not used on the endpoint |
| Endpoint user | postgres | no .<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.
- Project > Settings > Integrations > AWS PrivateLink (project-level page; nothing appears under organization settings).
- Add Account, enter your AWS account identifier and a description.
- 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:
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 youSHARE=$(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_arnWrite 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.
Step 6: verify the private path
Section titled “Step 6: verify the private path”From any host in the VPC (the lab uses a t3.micro reachable only through AWS Systems Manager, SSM):
# extract the presented chain once (STARTTLS); 3 certs expectedopenssl 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: PASSpsql "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): PASSpsql "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.
Step 7: close public access
Section titled “Step 7: close public access”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):
tofu plan -var 'public_access_cidrs=["x.x.x.x/32"]' -out=tfplan # network = jsonencode({ restrictions = ... })tofu apply tfplanPlan 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.
Step 8: migrations over the endpoint
Section titled “Step 8: migrations over the endpoint”The default supabase link targets the public shared pooler - broken by design the moment step 7 lands. Both correct paths:
supabase link --project-ref <ref> --skip-pooler # then: supabase db pushsupabase 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.
Verification
Section titled “Verification”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.
Gotchas and lessons learned
Section titled “Gotchas and lessons learned”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 :5432works whilepsql :6543hangs 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 declarativeaws_ram_resource_share_accepterdeadlocks 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_eachover 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_entryon 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.
/platformroutes 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 linkdefaults 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-pooleror--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: falseworkarounds 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 queuedand 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. pgbenchis not in thepostgresql16package. It ships inpostgresql16-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.
File reference
Section titled “File reference”| Path in supabase-lab | Role |
|---|---|
experiments/privatelink-aws/supabase.tf | step 1 + the gated restapi_object (step 2) |
experiments/privatelink-aws/lattice.tf | SG, endpoint, PHZ (steps 4-5) |
experiments/privatelink-aws/vpc.tf / runner.tf | VPC, network address translation (NAT) gateway, SSM-only runner |
experiments/privatelink-aws/lambda.tf | the VPC-attached Lambda probe (enable_lambda), incl. its log group so destroy is complete |
experiments/privatelink-aws/Makefile | phase1 arns phase2 suite restrict destroy suite-clean orchestration |
experiments/privatelink-aws/suite.sh | ships 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.md | per-run findings incl. measured numbers and errata |
References
Section titled “References”-
Supabase, “PrivateLink,” Supabase Docs. https://supabase.com/docs/guides/platform/privatelink ↩ ↩2 ↩3
-
AWS, “Resource configurations in VPC Lattice,” Amazon VPC Lattice User Guide. https://docs.aws.amazon.com/vpc-lattice/latest/ug/resource-configuration.html ↩
-
AWS, “What is AWS Resource Access Manager?,” AWS RAM User Guide. https://docs.aws.amazon.com/ram/latest/userguide/what-is.html ↩
-
Supabase, “supabase provider,” Terraform Registry. https://registry.terraform.io/providers/supabase/supabase/latest/docs ↩
-
Supabase, “Network Restrictions,” Supabase Docs. https://supabase.com/docs/guides/platform/network-restrictions ↩
-
HashiCorp, “aws_vpc_endpoint,” Terraform AWS Provider Registry. https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_endpoint ↩
-
Supabase, “supabase link,” Supabase CLI Reference. https://supabase.com/docs/reference/cli/supabase-link ↩