This post follows one request, curl hello.internal.company:8080, from my laptop to a small app in a different AWS account. The app has no public IP, and the two VPCs aren’t peered. Everything here comes from a two-account demo on GitHub.
The setup
Prod is the account that owns the app. It puts the app behind a Network Load Balancer (NLB) and publishes that NLB as an endpoint service.
Dev is the account that wants to call it. It creates an interface endpoint, which is a network interface with a private IP inside dev’s VPC, connected to prod’s endpoint service. Dev also runs AWS Client VPN, and that’s how my laptop gets into dev’s VPC.
Now the request.
The laptop joins dev’s network
The laptop isn’t in either VPC, so first it connects to Client VPN with the certificates the demo generates:
sudo openvpn --config ./dev.ovpn \
--cert scripts/certs/client.crt \
--key scripts/certs/client.key \
--ca scripts/certs/ca.crtIt gets an address from the VPN and a route to dev’s 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]That address comes from the VPN’s client range, 172.16.0.0/22 in the demo. AWS wants the range somewhere between /12 and /22, and it can’t overlap dev’s VPC. Prod’s range doesn’t matter at all, because the laptop never routes to prod.
Only traffic for dev’s VPC goes through the tunnel. That’s split tunnel, set in modules/client-vpn/main.tf, and here it’s not optional: dev’s VPC has no internet gateway or NAT, so with full tunnel the laptop would lose the internet. Full tunnel also wouldn’t change which DNS server the laptop uses. That’s a separate setting, and it’s the next problem.
The name has to resolve
First try:
$ curl hello.internal.company:8080
curl: (6) Could not resolve host: hello.internal.companyhello.internal.company only exists in a Route 53 private hosted zone attached to dev’s VPC. To see it, the laptop has to ask dev’s DNS resolver, which AWS puts at the VPC’s base address plus two: 10.10.0.2.
Client VPN does push that address to the client. The plain openvpn command on Linux ignores it unless you add a script to handle it, so I set it by hand:
# /etc/resolv.conf
nameserver 10.10.0.2NetworkManager or systemd-resolved will probably overwrite this. It’s fine for a test, but for daily use set DNS on the tunnel interface.
Second try:
$ curl hello.internal.company:8080
{"message": "Hello from provider", "ts": "2025-09-14T22:19:10.640669"}Crossing into the other account
The name resolved to the interface endpoint’s IP. That IP belongs to a network interface in dev’s own VPC, and nothing about it looks like another account. But anything sent to it comes out at prod’s NLB, which passes it to the app.
That’s what PrivateLink is. Dev doesn’t get a route into prod’s network. It gets one IP that leads to one service.
It only works in one direction, too. Dev opens connections and prod answers them, but prod can’t start anything toward dev. And since no packet ever routes between the two VPCs, their IP ranges are even allowed to overlap.
The leg from the laptop to the VPN goes over the internet, inside the encrypted tunnel. From the VPN on, the request stays on AWS’s network. The two accounts could also be in different regions if prod turns on cross-region access, but the demo keeps them together.
Who else can send this request
Anyone on the VPN. Security groups on the endpoint filter by IP address, not by person, and the demo’s certificate login can’t tell users apart anyway. If different people should reach different services, you need Client VPN authorization rules tied to user groups (through Active Directory or SAML), or separate endpoints.
Running it yourself
You need two AWS accounts with CLI profiles named dev and prod. Copy backend.hcl.example to backend.hcl in both envs/dev and envs/prod, and point them at your OpenTofu state bucket. Then:
git clone https://github.com/atakang7/cross-vpc-private-link.git
cd cross-vpc-private-link
bash first-run.shfirst-run.sh checks your tools, generates the VPN certificates, imports them into ACM, deploys prod, deploys dev, and exports dev.ovpn. Prod goes first because dev needs the endpoint service’s name to create its endpoint. After you connect, scripts/60_test_privateline.sh runs the same curl check.
To tear it down, run bash scripts/70_destroy_all.sh. It removes dev before prod, since AWS won’t delete an endpoint service that still has endpoints attached. The ACM certificates and your state bucket stay behind, so delete those yourself. Client VPN bills by the hour, so don’t leave it running overnight.