Private endpoints, VPC peering, and DNS for managed services
For developers wiring apps to managed databases, caches, and internal APIs without exposing traffic to the public internet. This guide explains when to use private endpoints versus peering, how DNS actually decides where traffic goes, and what to check when connections fail with timeouts, TLS hostname errors, or the wrong IP.
TL;DR — Private connectivity to managed services is usually a combination of two separate things: a network path (private endpoint or peering) and name resolution that points the service hostname at a private IP. The most common fix is not "open the firewall" but "query DNS from the same network as the app and confirm the hostname resolves to the provider-assigned private address, then verify routing/security rules to that subnet/port." Reading time: ~7 min
What it is and where it sits
When teams say "we want private access to the managed database," they usually mean: "our app should reach the service over RFC1918/private addressing inside our cloud network, not over the public internet, and the service should not need a public allowlist entry."
There are two moving parts:
-
The network attachment
- Private endpoint / PrivateLink-style attachment: the managed service is exposed to your network through one or more private IPs in your subnet or via provider-owned private addresses reachable from your VPC/VNet. Traffic goes to a specific service, not to the provider's whole network.
- Peering / routed private connectivity: your VPC/VNet gets routed access to another network. Traffic can reach more than one destination, depending on routes and policy.
-
DNS resolution
- Your app still connects to a hostname like
db.prod.example-provider.net. - DNS must return the private IP when queried from the app's network context, not the public IP you see from your laptop.
- If DNS is wrong, the app takes the public path even if private connectivity exists.
- Your app still connects to a hostname like
In a typical request flow, this sits between the application runtime and the managed service. It replaces one or more of these patterns:
- public service endpoint + IP allowlist
- NAT gateway egress to the internet
- VPN hairpinning back into the provider
- bastion/proxy hop used only to avoid public exposure
View as text
[App Pod/VM] ---- 1. lookup db.service.example ----> [Private DNS] [Private DNS] ---- 2. 10.42.8.15 ----> [App Pod/VM] [App Pod/VM] ---- 3. TCP 5432 to 10.42.8.15 ----> [Private Endpoint] [Private Endpoint] ---- 4. provider internal link ----> [Managed Postgres]
From a security/segmentation angle, private endpoints are usually narrower than peering. A private endpoint says "this subnet can talk to this service on these ports." Peering says "these CIDR ranges can route to each other," which is broader and easier to misuse.
How it actually works
Walk one concrete example: a Kubernetes app in a private subnet connects to a managed Postgres instance using a private endpoint and private DNS.
Step-by-step flow
- The app opens a TCP connection to the service hostname Your connection string is still hostname-based:
export DATABASE_URL='postgres://appuser:***@db-prod.internal.example:5432/app?sslmode=verify-full'
sslmode=verify-full matters. It checks that the certificate matches db-prod.internal.example. If you bypass DNS and connect directly to an IP, TLS hostname verification usually fails.
- The pod asks its configured resolver for the hostname On Linux, check what resolver the workload is actually using:
cat /etc/resolv.conf
Typical shape:
search svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
That cluster DNS server may forward to a VPC/VNet resolver, an on-prem DNS server, or a conditional forwarder. The key question is: from inside the workload network, what IP does the service hostname resolve to?
dig +short db-prod.internal.example
Expected output shape when private DNS is working:
10.42.8.15
10.42.8.16
Common bad output shape when the private zone is not linked or forwarding is wrong:
198.51.100.24
198.51.100.25
Those are public addresses. Your app will now try the public path.
- The packet follows the route to the private endpoint IP The private IP is either in your subnet or in a provider-managed range reachable from your network. Confirm the route from a VM in the same subnet class as the workload:
ip route get 10.42.8.15
Expected shape:
10.42.8.15 via 10.42.0.1 dev eth0 src 10.42.1.23 uid 1000
cache
If you get a route to a default internet gateway or NAT path, you are not using the private path you think you are.
- Security policy allows the connection
You need all three layers aligned:
- source egress rule from app subnet / pod security policy / security group
- destination ingress rule on the endpoint attachment or service policy
- any NACL/firewall between them
Test the TCP handshake before blaming Postgres:
nc -vz db-prod.internal.example 5432
Success shape:
Connection to db-prod.internal.example (10.42.8.15) 5432 port [tcp/postgresql] succeeded!
Failure shapes that matter:
nc: connect to db-prod.internal.example (10.42.8.15) port 5432 (tcp) failed: Connection timed out
Timeout usually means routing, NACL, firewall, or endpoint policy.
nc: connect to db-prod.internal.example (10.42.8.15) port 5432 (tcp) failed: Connection refused
Refused usually means you reached something at that IP, but the service/port is not listening or a middlebox is actively rejecting.
- TLS and protocol negotiation happen against the hostname
For Postgres,
psqlis a better test than raw TCP because it exercises TLS and auth:
psql "host=db-prod.internal.example port=5432 dbname=app user=appuser sslmode=verify-full"
Classic DNS/TLS mismatch error:
psql: error: connection to server at "db-prod.internal.example" (10.42.8.15), port 5432 failed: server certificate for "*.public.example-provider.net" does not match host name "db-prod.internal.example"
That tells you the private DNS name you invented does not match the certificate SANs. In practice, use the provider-issued service hostname and override its resolution privately; do not rename it unless the provider documents custom private DNS support.
The subtle but important part: DNS split-horizon
Most private connectivity setups rely on split-horizon DNS:
- from inside the private network,
db.service.exampleresolves to a private IP - from outside, the same name resolves to a public IP or does not resolve at all
This is why tests from your laptop are often misleading. dig from your office network can look fine or broken in the opposite direction of the app.
Use the resolver visible to the workload. If needed, run a temporary pod/VM in the same subnet and test there.
When to use it (and when not to)
| Scenario | Recommendation |
|---|---|
| App in cloud VPC/VNet talks to managed DB/cache in same provider, and you want no public exposure | Use a private endpoint first. It gives tighter segmentation than peering. |
| Many services in two networks need broad bidirectional access | Consider peering or transit routing, but treat it as network expansion, not just service access. |
| You only need outbound HTTPS to a managed API and the provider supports private service access | Prefer a private endpoint plus private DNS over peering. |
| You need cross-region, cross-account/project access to several private services | Use the provider-supported combination of private endpoints, shared DNS, and explicit route policy; avoid ad hoc peering meshes. |
| Small internal tool, low sensitivity, provider only offers public endpoint with TLS and IP allowlist | You probably don't need private connectivity. Public endpoint + TLS + least-privilege allowlist is often enough. |
| Team cannot operate DNS confidently and has no observability into resolvers | Do not start with split-horizon private DNS unless the risk justifies it; misresolution causes hard-to-diagnose outages. |
You probably don't need this if:
- the service is already public-only and protected by strong auth/TLS, and your threat model does not require private routing
- your main goal is "faster"; private paths are about exposure and policy, not guaranteed lower latency
- the app runs outside the provider network and would need VPN/Direct Connect/ExpressRoute-class plumbing anyway
Trade-offs
| Benefit | What it costs |
|---|---|
| Removes public exposure and internet egress for service traffic | More DNS complexity; outages often become resolver/linking issues instead of obvious firewall issues. |
| Tighter segmentation with service-specific endpoints | Per-endpoint cost, per-AZ/subnet planning, and more objects to manage. |
| Cleaner compliance story: private address space, no public allowlists | Harder troubleshooting from a developer laptop; you need in-network test hosts and better runbooks. |
| Can reduce dependence on NAT gateways/public egress | Not always cheaper; endpoint hourly/data-processing charges can exceed simple public egress in low-volume systems. |
| Peering enables broad private communication | Broad blast radius: route leaks, overlapping CIDRs, and accidental lateral movement become real concerns. |
| Private DNS preserves the provider hostname for TLS validation | Split-horizon DNS can surprise CI runners, local dev, and hybrid/on-prem resolvers if forwarding is incomplete. |
The lock-in question is real. "Private endpoint" implementations differ across clouds. The portable design principle is: keep application config hostname-based, keep TLS hostname verification on, and treat private resolution/routing as infrastructure concerns. That makes migration less painful.
In practice
Example 1: Verify DNS and TCP path from the same network as the app
# Run from a VM or debug pod in the same subnet/VPC/VNet as the workload.
getent hosts db-prod.internal.example
dig +short db-prod.internal.example A
dig +short db-prod.internal.example AAAA
ip route get $(dig +short db-prod.internal.example A | head -n1)
nc -vz db-prod.internal.example 5432
openssl s_client -connect db-prod.internal.example:5432 -starttls postgres -servername db-prod.internal.example </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -ext subjectAltName
This checks name resolution, routing, TCP reachability, and certificate names from the actual network context that matters. The gotcha: if AAAA returns an IPv6 address and your network has partial IPv6, clients may prefer it and hang; either fix IPv6 routing or remove the broken AAAA record.
Example 2: Nginx upstream pinned to hostname, not IP
resolver 10.0.0.2 valid=30s ipv6=off;
upstream internal_api {
zone internal_api 64k;
server api.service.example:443 resolve;
}
server {
listen 443 ssl;
location / {
proxy_pass https://internal_api;
proxy_ssl_server_name on;
proxy_ssl_name api.service.example;
}
}
This tells Nginx to re-resolve the upstream hostname and send SNI for the hostname, which is what you want when private DNS maps that name to rotating private endpoint IPs. The gotcha: without resolve and a resolver, Nginx may cache the startup-time IP indefinitely and keep using a dead endpoint after failover.
Example 3: Postgres client test that catches the real class of failure
export PGCONNECT_TIMEOUT=5
psql "host=db-prod.internal.example port=5432 dbname=app user=appuser sslmode=verify-full" -c 'select now();'
echo $?
Expected success shape:
now
-------------------------------
2026-10-30 11:42:13.123456+00
(1 row)
0
Typical failure shapes:
psql: error: connection to server at "db-prod.internal.example" (10.42.8.15), port 5432 failed: timeout expired
2
psql: error: connection to server at "db-prod.internal.example" (198.51.100.24), port 5432 failed: server certificate for "*.provider.example" does not match host name "db-prod.internal.example"
2
This is better than telnet because it tells you whether you have a network problem, a DNS/public-path problem, or a TLS hostname problem. The gotcha: sslmode=require would hide certificate-name issues; use verify-full for diagnostics and production.
⚠️ If you change DNS records or attach a private zone to a production network, expect a real outage window if the record is wrong. Lower TTL first, change one service hostname at a time, and test from an in-network host before switching application traffic.
Further reading
- RFC 1918: Address Allocation for Private Internets
- RFC 1034 and RFC 1035: Domain Names - Concepts and Facilities / Implementation and Specification
- RFC 2308: Negative Caching of DNS Queries
- PostgreSQL Documentation: SSL Support
- NGINX Documentation:
resolver, upstreamresolve, and proxy SSL server name
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI