
A NAT Gateway does two things at once, and most teams only think about one of them. It lets private-subnet workloads reach the internet — and it bills per gigabyte for every byte that passes through it, on top of its hourly charge. Pulling container images from ECR, writing logs to CloudWatch, reading secrets from Secrets Manager — none of that is "the internet" in any meaningful sense, but if it's routed through a NAT Gateway, it's billed and reachable exactly like internet traffic is. VPC Endpoints give AWS-service traffic a private path that skips the NAT Gateway entirely — often for less money, and always with a smaller attack surface.
Two kinds of endpoint, two different mechanics
- Gateway endpoints exist for S3 and DynamoDB only. They work as a route table entry — no new network interface, no hourly charge, no per-GB charge. If your subnet's route table points S3 traffic at the gateway endpoint, that traffic simply never leaves the AWS network.
- Interface endpoints (built on AWS PrivateLink) exist for most other AWS services — ECR, CloudWatch Logs, Secrets Manager, STS, SQS, SNS, KMS, and dozens more. Each one creates an elastic network interface with a private IP in your subnet, and DNS resolution for the service's normal hostname is transparently redirected to it when private DNS is enabled. These do carry an hourly charge per AZ plus a per-GB data processing charge — but for AWS-API traffic that would otherwise cross a NAT Gateway (which charges its own per-GB fee, on a path that also has to traverse the public internet), an interface endpoint is almost always the cheaper of the two metered paths. Exact rates vary by region and change over time, so check current pricing before building a cost model — the structural point holds regardless: Gateway endpoints remove the NAT charge entirely for S3/DynamoDB, and Interface endpoints replace one metered hop with a cheaper one for everything else.
The security angle, not just the cost one
A NAT Gateway is, by design, a route to anywhere on the internet. Nothing about it restricts a compromised workload to only the AWS APIs it legitimately needs — if your security groups allow outbound 443, a compromised pod can exfiltrate data to any HTTPS endpoint on the internet through that same NAT Gateway. A VPC endpoint has no such generality: it's a path to exactly one AWS service, and you can attach an endpoint policy that restricts it further — to specific S3 buckets, specific Secrets Manager secrets, specific anything the service's policy language supports. Moving AWS-API traffic off NAT and onto endpoints doesn't just save money; it removes that traffic from the set of things a NAT-based egress review even needs to consider.
Step 1: A Gateway endpoint for S3, restricted to one bucket
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
}
resource "aws_vpc_endpoint_policy" "s3_restricted" {
vpc_endpoint_id = aws_vpc_endpoint.s3.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = [
"arn:aws:s3:::my-app-artifacts",
"arn:aws:s3:::my-app-artifacts/*"
]
}]
})
}
Without the policy, the endpoint defaults to allowing all S3 actions on all buckets reachable by whatever IAM credentials are making the call — the endpoint itself doesn't restrict anything unless you tell it to. The policy above narrows the network path itself to one bucket, as a second layer behind IAM, not a replacement for it.
Step 2: Interface endpoints for ECR
Pulling images needs two separate endpoints — the ECR API (auth, image metadata) and the Docker registry protocol endpoint that actually serves image layers:
resource "aws_security_group" "vpc_endpoints" {
name = "vpc-endpoints"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
}
}
resource "aws_vpc_endpoint" "ecr_api" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.ecr.api"
vpc_endpoint_type = "Interface"
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
resource "aws_vpc_endpoint" "ecr_dkr" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.ecr.dkr"
vpc_endpoint_type = "Interface"
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
private_dns_enabled = true is what makes this transparent to existing tooling — docker pull and kubectl keep using the normal *.dkr.ecr.<region>.amazonaws.com hostname, and DNS quietly resolves it to the endpoint's private IP instead of a public one. Without it, you'd need to reconfigure every client to use the endpoint's own DNS name explicitly.
ECR images referencing layers stored in S3 still need the S3 gateway endpoint from Step 1 in the same route table — ECR's control plane goes through the interface endpoint, but the actual layer blobs are served from S3.
Step 3: Confirm the route is actually private
From an instance or pod in the private subnet:
nslookup api.ecr.us-east-1.amazonaws.com
# Should resolve to a private 10.x/172.x/192.168.x address, not a public one,
# once the interface endpoint's private DNS is active.
aws ec2 describe-vpc-endpoints --filters Name=vpc-id,Values=$VPC_ID \
--query 'VpcEndpoints[].{Service:ServiceName,State:State,Type:VpcEndpointType}'
An audit checklist: what's crossing NAT that doesn't need to
Services most teams are routing through NAT today that have a direct endpoint equivalent:
- Gateway (free): S3, DynamoDB
- Interface: ECR (
api+dkr), CloudWatch Logs, Secrets Manager, STS, SSM, SQS, SNS, KMS
A quick way to see what's actually using the NAT Gateway: enable VPC Flow Logs on the NAT Gateway's elastic network interface and query the destination IPs against AWS's published IP ranges for each service — traffic matching an AWS service range is a candidate for an endpoint; traffic that doesn't is genuine third-party internet egress and still needs NAT.
When NAT is still the right answer
VPC endpoints only cover AWS services (plus whatever third parties publish their own PrivateLink endpoint, which is rare outside major SaaS vendors). A workload that calls a genuine external API — a payment processor, a weather service, an arbitrary SaaS webhook — still needs a real route to the internet, and that's exactly what NAT Gateway is for. The realistic end state for most clusters isn't "no NAT Gateway," it's a NAT Gateway handling a much smaller, genuinely-external slice of traffic, with everything AWS-internal moved onto endpoints.
Common pitfalls
- Forgetting the security group on interface endpoints. Unlike gateway endpoints, interface endpoints are ENIs with their own security group — no ingress rule means no traffic reaches them, and the failure looks like a generic timeout, not an obvious permissions error.
- Private DNS left off. Without it, existing SDKs and CLIs keep resolving the public hostname and routing out through NAT anyway — the endpoint exists but nothing actually uses it.
- Assuming the default endpoint policy restricts anything. It doesn't, until you attach one. An endpoint with no policy is wide open to any resource of that service type your credentials can reach.
- Endpoints in the wrong subnets. Interface endpoints need to be reachable from the subnets whose traffic you want redirected — deploying them only in one AZ when workloads run in three leaves two AZs still routing through NAT.
Where to go next
Once AWS-service traffic is off NAT, the NAT Gateway's own flow logs become a much more useful signal — what's left crossing it is, almost by definition, either genuine third-party egress or something that should have an endpoint and doesn't yet.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


