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.
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.shfirst-run.sh is a chain of small scripts. Each does one thing.
| Step | Script | What it does |
|---|---|---|
| 0 | 00_check_prereqs.sh | Verifies tofu, aws, openssl are on PATH |
| 1 | 10_generate_certs.sh | Generates CA, server, and client certs |
| 2 | 20_import_acm.sh | Uploads the server cert and CA chain to ACM in the consumer account |
| 3 | 30_deploy_prod.sh | Stands up the producer: VPC, NLB, endpoint service, demo app |
| 4 | 40_deploy_dev.sh | Stands up the consumer: VPC, interface endpoint, Route 53 private zone, Client VPN |
| 5 | 50_export_vpn_config.sh | Pulls the .ovpn file off the Client VPN endpoint |
| 6 | 60_test_privateline.sh | Curls the private hostname through the tunnel |
| 7 | 70_destroy_all.sh | Tears 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.crtWhen 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.companyPoint at the consumer VPC’s .2 DNS:
# /etc/resolv.conf
nameserver 10.10.0.2Try 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.