TL;DR — Quick Wins
# 1. Check how much NAT Gateway is costing you
aws ce get-cost-and-usage \
--time-period Start=2026-07-01,End=2026-07-31 \
--granularity MONTHLY \
--metrics "UnblendedCost" \
--filter '{"Dimensions":{"Key":"USAGE_TYPE","Values":["NatGateway-Bytes"]}}'
# 2. Add S3 Gateway Endpoint (free, saves the most)
aws ec2 create-vpc-endpoint \
--vpc-id vpc-xxx \
--service-name com.amazonaws.us-east-1.s3 \
--route-table-ids rtb-xxx
# 3. Add DynamoDB Gateway Endpoint (free)
aws ec2 create-vpc-endpoint \
--vpc-id vpc-xxx \
--service-name com.amazonaws.us-east-1.dynamodb \
--route-table-ids rtb-xxx
These two changes alone typically reduce NAT costs by 30-50%.
---
Why NAT Gateway Bills Explode
AWS NAT Gateway charges apply in two ways:
The data processing is what kills you. A busy microservices cluster doing 1 TB/day of outbound traffic through NAT costs ~$2,700/month in processing alone.
Step 1: Identify What's Going Through NAT
Enable VPC Flow Logs
resource "aws_flow_log" "nat_analysis" {
vpc_id = aws_vpc.main.id
traffic_type = "ALL"
log_destination_type = "s3"
log_destination = aws_s3_bucket.flow_logs.arn
destination_options {
file_format = "parquet"
}
}
Query Flow Logs with Athena
SELECT
dstaddr,
SUM(bytes) / 1073741824 as gb_transferred,
COUNT(*) as connection_count
FROM vpc_flow_logs
WHERE interface_id = 'eni-nat-gateway-id'
AND action = 'ACCEPT'
AND start >= DATE '2026-07-01'
GROUP BY dstaddr
ORDER BY gb_transferred DESC
LIMIT 20;
Step 2: VPC Gateway Endpoints (Free — Biggest Impact)
Gateway Endpoints route traffic to S3 and DynamoDB directly without touching NAT. They cost nothing.
S3 Gateway Endpoint
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
route_table_ids = [
aws_route_table.private_a.id,
aws_route_table.private_b.id,
aws_route_table.private_c.id,
]
tags = {
Name = "s3-gateway-endpoint"
}
}
What this saves:
- ECR image pulls (Docker images stored in S3)
- CloudWatch Logs delivery
- ALB access logs
- Any S3 API calls from private subnets
DynamoDB Gateway Endpoint
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.dynamodb"
route_table_ids = [
aws_route_table.private_a.id,
aws_route_table.private_b.id,
aws_route_table.private_c.id,
]
tags = {
Name = "dynamodb-gateway-endpoint"
}
}
Step 3: VPC Interface Endpoints (PrivateLink)
Interface Endpoints cost $0.01/hour + $0.01/GB — still much cheaper than NAT processing at $0.045/GB.
High-Value Interface Endpoints
# ECR Endpoints — stop image pulls from going through NAT
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 = var.private_subnet_ids
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 = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
# CloudWatch Logs — high-volume log shipping
resource "aws_vpc_endpoint" "logs" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.logs"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
# STS — every IAM role assume goes through this
resource "aws_vpc_endpoint" "sts" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.sts"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
# Security group for VPC endpoints
resource "aws_security_group" "vpc_endpoints" {
name_prefix = "vpc-endpoints-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
}
}
Priority Order for Interface Endpoints
Step 4: Cross-AZ NAT Optimization
A common mistake: using a single NAT Gateway in one AZ and routing all private subnets to it.
Before (Expensive)
Private Subnet AZ-a --cross-AZ--> NAT Gateway (AZ-b) --> Internet
Private Subnet AZ-b ------------> NAT Gateway (AZ-b) --> Internet
Private Subnet AZ-c --cross-AZ--> NAT Gateway (AZ-b) --> Internet
After (One NAT Per AZ)
resource "aws_nat_gateway" "per_az" {
for_each = toset(var.availability_zones)
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.public[each.key].id
tags = {
Name = "nat-${each.key}"
}
}
resource "aws_route" "private_nat" {
for_each = toset(var.availability_zones)
route_table_id = aws_route_table.private[each.key].id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.per_az[each.key].id
}
Trade-off: More NAT Gateways = more hourly cost ($32/month each), but eliminates cross-AZ charges. Net positive if cross-AZ traffic exceeds 3.2 TB/month.
Step 5: NAT Instance for Dev/Staging
For non-production environments, replace NAT Gateway with a NAT instance:
resource "aws_instance" "nat" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t4g.nano"
subnet_id = aws_subnet.public[0].id
source_dest_check = false
vpc_security_group_ids = [aws_security_group.nat.id]
user_data = <<-EOF
#!/bin/bash
yum install -y iptables-services
sysctl -w net.ipv4.ip_forward=1
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
service iptables save
EOF
tags = {
Name = "nat-instance-dev"
}
}
| Criteria | NAT Instance | NAT Gateway |
|---|---|---|
| Cost (low traffic) | ~$3-10/month | ~$32/month |
| Bandwidth | Limited by instance type | Up to 100 Gbps |
| High availability | Manual (ASG needed) | Built-in per AZ |
| Use case | Dev/staging | Production |
Step 6: Application-Level Optimization
Pull-Through Cache for Container Images
# Create ECR pull-through cache rule for Docker Hub
aws ecr create-pull-through-cache-rule \
--ecr-repository-prefix docker-hub \
--upstream-registry-url registry-1.docker.io
# Now pull from ECR instead of Docker Hub (stays in VPC via S3 endpoint)
# Before: docker.io/library/nginx:latest (goes through NAT)
# After: 123456789.dkr.ecr.us-east-1.amazonaws.com/docker-hub/library/nginx:latest
Cache External API Responses
# Use Redis/ElastiCache to cache outbound API calls
env:
- name: HTTP_CACHE_TTL
value: "300" # Cache external API responses for 5 min
Cost Savings Summary
| Optimization | Effort | Typical Savings |
|---|---|---|
| S3 Gateway Endpoint | 5 min | 20-40% |
| DynamoDB Gateway Endpoint | 5 min | 5-10% |
| ECR Interface Endpoints | 15 min | 10-20% |
| CloudWatch Logs Endpoint | 10 min | 5-15% |
| Per-AZ NAT placement | 30 min | 10-20% |
| NAT Instance for dev | 1 hour | 90% for non-prod |
| Pull-through image cache | 20 min | 5-10% |
Combined savings: 60-80% reduction in NAT costs.
---
Frequently Asked Questions
How much does a NAT Gateway cost per month?
A NAT Gateway costs $0.045/hour ($32.40/month) plus $0.045 per GB of data processed. For a typical production setup with 3 AZs and moderate traffic (500 GB/day), the total cost can exceed $3,000/month. The data processing charge is usually the larger portion of the bill.
What's the difference between Gateway and Interface VPC Endpoints?
Gateway Endpoints (S3, DynamoDB only) are free, route-table-based, and don't require a security group. Interface Endpoints (all other AWS services) cost $0.01/hour + $0.01/GB, use ENIs in your subnet, and require security groups. Both eliminate NAT Gateway data processing charges for their respective services.
Should I use one NAT Gateway or one per AZ?
Use one per AZ in production to eliminate cross-AZ data transfer charges and improve fault isolation. A single NAT Gateway saves $64/month in hourly charges but costs $0.02/GB in cross-AZ fees. If your cross-AZ traffic exceeds ~3.2 TB/month, multiple NAT Gateways are cheaper overall.
Can I completely eliminate NAT Gateway?
Only if all your outbound traffic goes to AWS services (covered by VPC Endpoints) and you don't need internet access from private subnets. Most production workloads still need NAT for third-party API calls, package updates, and external integrations. The goal is to minimize what flows through NAT, not eliminate it entirely.
---
Related Resources
- VPC Design & Subnet Sizing Guide — Network architecture best practices
- AWS EKS Production Guide — Running Kubernetes on AWS
- DevOps Infrastructure Sizing Guide — Right-sizing cloud resources