A flow log analysis shows that 70% of the VPC’s NAT egress is going to S3 IP ranges. The team’s CI runners pull build artifacts from S3 a few thousand times an hour; the application servers stream logs to S3; backups go to S3. Every byte goes out the NAT Gateway and gets billed per GB. Adding a gateway endpoint for S3 takes one route table change and drops the NAT bill by roughly 70% overnight. The same is true for DynamoDB, smaller in volume but free anyway. Interface endpoints for ECR cost a small hourly fee per AZ but save a larger per-GB charge on container image pulls. The math is one-sided enough that any VPC running NAT without endpoints is leaving money on the table. Here is the working setup.
What’s happening
A VPC endpoint lets traffic between your VPC and an AWS service flow over AWS’s internal network without ever transiting an internet gateway or NAT Gateway. There are two endpoint types:
Gateway endpoints — for S3 and DynamoDB only. No hourly charge. No per-GB charge. Implemented as a route table entry: the prefix list for the service (e.g. pl-63a5400a for S3 in us-east-1) routes to the gateway endpoint, which AWS resolves to whichever S3 endpoint serves the calling subnet. Traffic from instances in the private subnet to S3 hits the endpoint, bypasses NAT, and bypasses the internet gateway. The traffic still appears to S3 as coming from the VPC’s CIDR, which is what makes S3 bucket policies that gate by source VPC work.
Interface endpoints — for every other AWS service and for PrivateLink to third-party services. Implemented as an ENI in each AZ’s subnet, with an ENI IP from your subnet’s CIDR. Hourly charge per AZ per endpoint. Per-GB charge on data processed (lower than NAT Gateway’s). Each AZ gets one ENI; the AWS SDK resolves the service’s regional hostname to the appropriate ENI IP through Route 53 private DNS (if you enable private DNS on the endpoint).
The cost math:
- A gateway endpoint for S3 is free. The break-even is zero — if you have any S3 traffic at all, the endpoint pays for itself instantly.
- An interface endpoint costs an hourly fee per AZ. For three AZs, that is a modest hourly cost. The break-even against NAT Gateway depends on the data-processing rate differential and the volume. Roughly: if the service moves more than 10 GB/month per AZ, the interface endpoint is cheaper than NAT.
- For services with very low traffic (a Step Functions API call once a day), the interface endpoint may not break even on cost — but the security and routing benefits (no public path) often justify it anyway.
The DNS gotcha: when you enable private DNS on an interface endpoint, the service’s regional hostname (api.ecr.us-east-1.amazonaws.com) gets resolved by your VPC’s Route 53 resolver to the endpoint’s ENI IP. Inside the VPC that owns the endpoint, this works automatically. Across a Transit Gateway or VPC peering, the DNS resolution does NOT cross — the peer VPC’s resolver does not know about your endpoint’s private DNS. Cross-account or cross-VPC consumers of an interface endpoint need their own endpoint, or they need a Route 53 private hosted zone shared via Resource Access Manager and associated with their VPC.
How to verify
The list of services with gateway endpoints versus interface endpoints:
| Service | Endpoint type | Cost |
|---|---|---|
| S3 | gateway (also has interface for cross-region/cross-account use) | free |
| DynamoDB | gateway only | free |
| ECR (registry) | interface | hourly + per-GB |
| ECR (docker pull) | interface (*.dkr.ecr.*) | hourly + per-GB |
| KMS | interface | hourly + per-GB |
| Secrets Manager | interface | hourly + per-GB |
| Systems Manager (SSM) | interface (com.amazonaws.<region>.ssm, ssmmessages, ec2messages) | hourly + per-GB |
| STS | interface | hourly + per-GB |
| CloudWatch Logs | interface | hourly + per-GB |
| SQS | interface | hourly + per-GB |
| SNS | interface | hourly + per-GB |
| Lambda | interface | hourly + per-GB |
| API Gateway (private APIs) | interface | hourly + per-GB |
Inspect current endpoints:
aws ec2 describe-vpc-endpoints \
--filters Name=vpc-id,Values=vpc-0123456789abcdef0 \
--query 'VpcEndpoints[].[VpcEndpointId,ServiceName,VpcEndpointType,State]' \
--output table
Inspect what should have one (based on flow logs):
-- Athena on flow logs: top egress destinations resolving to AWS service IPs
SELECT
dstaddr,
SUM(bytes) AS total_bytes
FROM vpc_flow_logs
WHERE year = '2026' AND month = '5' AND day = '25'
AND flow_direction = 'egress'
AND NOT regexp_like(dstaddr, '^10\.')
GROUP BY dstaddr
ORDER BY total_bytes DESC
LIMIT 50;
Cross-reference the top IPs against the AWS service IP ranges (https://ip-ranges.amazonaws.com/ip-ranges.json). Any IP in an AWS service’s range that is generating significant egress is a candidate for an endpoint.
The fix
The deployment procedure for a VPC that currently has no endpoints:
Step 1 — gateway endpoints for S3 and DynamoDB. Both are free. Deploy them first.
# Find the route tables for the private subnets
PRIVATE_RTBS=$(aws ec2 describe-route-tables \
--filters Name=vpc-id,Values=vpc-0123456789abcdef0 Name=tag:Tier,Values=private \
--query 'RouteTables[].RouteTableId' --output text)
# Create S3 gateway endpoint, attached to all private route tables
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--vpc-endpoint-type Gateway \
--service-name com.amazonaws.us-east-1.s3 \
--route-table-ids $PRIVATE_RTBS
# Same for DynamoDB
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--vpc-endpoint-type Gateway \
--service-name com.amazonaws.us-east-1.dynamodb \
--route-table-ids $PRIVATE_RTBS
Verify from a private-subnet instance: aws s3 cp s3://bucket/key /tmp/test should succeed; the flow log entry for the call should show the source as the instance’s private IP and the destination as the AWS S3 service IP, with the traffic NOT going through the NAT Gateway’s ENI.
Watch the NAT egress bill the day after. S3 traffic should drop to near zero.
Step 2 — interface endpoints for the heavyweight services. Pick from flow log evidence. The usual top three are ECR (for container pulls), CloudWatch Logs (for log streaming), and Systems Manager (for SSM session manager and patch baselines).
# Create a security group for the interface endpoints
# Endpoint SG allows inbound 443 from the VPC CIDR
aws ec2 create-security-group \
--group-name sg-vpc-endpoints \
--description "Allow 443 from VPC to interface endpoints" \
--vpc-id vpc-0123456789abcdef0
aws ec2 authorize-security-group-ingress \
--group-id sg-vpc-endpoints-id \
--protocol tcp --port 443 --cidr 10.20.0.0/16
# ECR registry endpoint (the API)
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--vpc-endpoint-type Interface \
--service-name com.amazonaws.us-east-1.ecr.api \
--subnet-ids subnet-private-1a subnet-private-1b subnet-private-1c \
--security-group-ids sg-vpc-endpoints-id \
--private-dns-enabled
# ECR docker endpoint (the image pull)
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--vpc-endpoint-type Interface \
--service-name com.amazonaws.us-east-1.ecr.dkr \
--subnet-ids subnet-private-1a subnet-private-1b subnet-private-1c \
--security-group-ids sg-vpc-endpoints-id \
--private-dns-enabled
ECR specifically requires both endpoints — the registry API for authentication and the dkr endpoint for the actual image pull. With both in place and S3 gateway endpoint already deployed (image layers live in S3 behind ECR), a docker pull from a private-subnet instance flows entirely through endpoints.
Step 3 — verify the routing. From a private-subnet instance:
# Should resolve to a 10.x.x.x address (the ENI), not a public AWS IP
dig api.ecr.us-east-1.amazonaws.com
# Should succeed and not show up in NAT flow logs
aws ecr get-login-password --region us-east-1
Step 4 — cross-account and cross-VPC interface endpoint access. This is where the DNS gotcha bites. If an interface endpoint lives in account A’s VPC, and account B’s VPC needs to use it via a Transit Gateway:
- Disable private DNS on the endpoint (or accept that the consumer cannot use the regional hostname).
- Create a Route 53 private hosted zone for the service hostname in account B’s account, with a CNAME or alias pointing at the endpoint’s regional DNS name (e.g.
vpce-12345-abcdef.api.ecr.us-east-1.vpce.amazonaws.com). - Associate the private hosted zone with account B’s VPC.
- Allow TCP 443 from B’s VPC CIDR on the endpoint’s SG.
- Confirm the routing — TGW route tables must allow B’s VPC to reach A’s VPC (the endpoint ENI IP).
The simpler alternative: every consuming VPC creates its own endpoint. The hourly cost is higher; the DNS plumbing disappears.
Step 5 — bucket policies and endpoint policies, if you need them. Both gateway and interface endpoints support endpoint policies that restrict which AWS principals or resources can be accessed through the endpoint. The common pattern is to scope an S3 gateway endpoint to “only buckets in this account” to prevent data exfiltration to a third-party S3 bucket. Endpoint policies are JSON, similar to IAM, and apply at the endpoint level.
The most-common mistake: enabling private DNS on an interface endpoint and then wondering why the AWS SDK from a peer VPC fails. Private DNS only works in the VPC that owns the endpoint unless you share the hosted zone. Either give every consuming VPC its own endpoint, or share the DNS explicitly.
The other rule we hold: endpoints are the cheapest single AWS bill optimization for almost any VPC. The S3 gateway endpoint costs nothing to deploy and pays for itself the first day. The interface endpoints for ECR, CloudWatch Logs, and SSM pay for themselves within a month at any non-trivial workload size. The exception is a development VPC with very low traffic — there, the hourly endpoint cost may not break even, and the simpler answer is “leave it on NAT, this VPC moves five GB a month.”
For the egress side of the same decision, see NAT Gateway vs NAT instance. For the flow log analysis that identifies endpoint candidates, see VPC Flow Logs analysis. For the cross-account version of the connectivity, see VPC peering vs PrivateLink.
Stack Harbor deploys endpoint architectures as part of NAT-bill audits in Managed AWS and ongoing Managed Cloud Ops. If your private subnets are paying NAT prices for traffic that should be on an endpoint, start with an infrastructure consult.