PrivateLink across two AWS accounts

One VPC reaching another over AWS's backbone, no public IPs. A working two-account demo, and the parts that bit me.

← Writing

Exposing an internal service through the public internet is a bad default. Most companies push everything through private networking, and every major cloud has its own version of it. This post is about the AWS one: PrivateLink. There’s a working two-account demo on GitHub.

The shape of it

PrivateLink lets a service in one VPC accept connections from another VPC — across accounts, across regions — without the traffic ever leaving AWS’s network. The data path is one-way and the consumer never sees the producer’s VPC.

The model is producer / consumer:

  • Producer runs the service behind a Network Load Balancer and exposes it as a VPC Endpoint Service.
  • Consumer creates an Interface VPC Endpoint that shows up in its VPC as a normal ENI. The service is reachable on a private IP, like any other internal address.
CONSUMER ACCOUNT · DEV VPC 10.10.0.0/16
Remote user
VPN
Client VPNendpoint
DNS
Interface endpointENI in private subnet
Route 53private DNS
PrivateLink
PRODUCER ACCOUNT · PROD VPC 10.20.0.0/16
VPC endpointservice
Network load balancer
cross-zone
App targetAZ-a
App targetAZ-b
The data path crosses accounts only at the PrivateLink hop (gold). Everything in the top band lives in the consumer’s VPC; everything below, in the producer’s.

Enable cross-zone load balancing on the NLB and traffic spreads across targets in every AZ, regardless of where the client’s interface endpoint lives.

Try it

You need two AWS accounts. Credentials configured for both.

git clone https://github.com/atakang7/cross-vpc-private-link.git
cd cross-vpc-private-link
bash first-run.sh

first-run.sh is a chain of small scripts. Each does one thing.

StepScriptWhat it does
000_check_prereqs.shVerifies tofu, aws, openssl are on PATH
110_generate_certs.shGenerates CA, server, and client certs
220_import_acm.shUploads the server cert and CA chain to ACM in the consumer account
330_deploy_prod.shStands up the producer: VPC, NLB, endpoint service, demo app
440_deploy_dev.shStands up the consumer: VPC, interface endpoint, Route 53 private zone, Client VPN
550_export_vpn_config.shPulls the .ovpn file off the Client VPN endpoint
660_test_privateline.shCurls the private hostname through the tunnel
770_destroy_all.shTears it all down — consumer first, then producer

Order in step 7 matters. The interface endpoint depends on the endpoint service, so destroying the producer first leaves orphaned resources that block the consumer destroy.

Connecting and proving it works

After the deploy, connect through OpenVPN with the generated client cert:

sudo openvpn --config ./dev.ovpn \
  --cert scripts/certs/client.crt \
  --key  scripts/certs/client.key \
  --ca   scripts/certs/ca.crt

When it’s up you’ll see the tunnel pick up an address in the VPN’s CIDR and add a route into the consumer VPC:

Initialization Sequence Completed
net_addr_v4_add: 172.16.0.2/27 dev tun0
net_route_v4_add: 10.10.0.0/16 via 172.16.0.1 dev [NULL]

First curl fails — DNS isn’t pointing at the consumer VPC’s resolver yet:

$ curl hello.internal.company:8080
curl: (6) Could not resolve host: hello.internal.company

Point at the consumer VPC’s .2 DNS:

# /etc/resolv.conf
nameserver 10.10.0.2

Try again:

$ curl hello.internal.company:8080
{"message": "Hello from provider", "ts": "2025-09-14T22:19:10.640669"}

Route 53 resolves the private zone to the interface endpoint’s ENI, the ENI sends the request over PrivateLink to the producer’s NLB, the NLB forwards to the demo app. The whole hop chain stays inside AWS.

Things that bit me

Overlapping CIDRs. The VPN client CIDR (172.16.0.0/27 here) must not overlap with either VPC CIDR. If it does, the routing table on your laptop gets ambiguous and connections fail in ways that look like firewall problems.

Split tunnel vs. full tunnel. With split_tunnel = true, only routes to the consumer VPC go through the VPN. Everything else uses the local network. If you want company DNS to be the only resolver — a common requirement — set it to false:

resource "aws_ec2_client_vpn_endpoint" "this" {
  # ...
  split_tunnel = false
}

That’s the trade. Full tunnel is more locked down; split tunnel keeps the rest of your laptop’s internet on your normal connection.

Private DNS scope. The Route 53 private zone is only resolvable from inside the consumer VPC, which means anyone connected to the Client VPN sees it. If a service should only be reachable from a subset of users, layer security groups on the interface endpoint — DNS isn’t access control.