NAT Gateway Alternatives That Saved $42K/Month in Egress Costs
NAT gateway alternatives and optimization patterns that reduced egress costs from $54K to $12K per month without sacrificing security.

NAT Gateways are AWS's most expensive per-byte networking component. At $0.045/GB for data processing plus the underlying $0.09/GB internet egress, every byte leaving your private subnets through a NAT Gateway costs $0.135/GB total. When we audited our networking costs, NAT Gateway processing alone was $54K/month — more than our entire compute fleet. Here's how we reduced it to $12K/month. For broader cost optimization context, see also AWS Elastic IP hidden charges and the complete data transfer cost optimization guide.
The NAT Gateway Cost Trap
NAT Gateways charge for two things:
- Hourly charge: $0.045/hour (~$32.85/month per AZ)
- Data processing: $0.045/GB for every byte processed
The hourly charge is minor. The per-GB processing charge is devastating at scale. And here's what most teams miss: the processing charge applies to ALL traffic through the NAT Gateway, including traffic to AWS services that could use VPC endpoints instead.
Our NAT Gateway bill breakdown:
| Traffic Type | Monthly GB | Processing Cost | Could Be Eliminated? |
|---|---|---|---|
| S3 API calls | 128,000 GB | $5,760 | Yes (gateway endpoint) |
| ECR image pulls | 42,000 GB | $1,890 | Yes (interface endpoint) |
| CloudWatch/Logs | 18,000 GB | $810 | Yes (interface endpoint) |
| SQS/SNS | 8,400 GB | $378 | Yes (interface endpoint) |
| DynamoDB | 34,000 GB | $1,530 | Yes (gateway endpoint) |
| Third-party APIs | 12,000 GB | $540 | No (needs internet) |
| Software updates | 6,000 GB | $270 | Partial (proxy/cache) |
| Total | 248,400 GB | $11,178 |
230,400 GB (93%) of our NAT Gateway traffic was going to AWS services that support VPC endpoints. We were paying $0.045/GB to route traffic through NAT when it could route for free (gateway endpoints) or for $0.01/GB (interface endpoints).
Strategy 1: VPC Endpoints (Biggest Impact)
Gateway endpoints (S3, DynamoDB) are free. Interface endpoints cost $0.01/GB — still 77% cheaper than NAT Gateway processing.
# Free gateway endpoints — immediate savings
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.production.id
service_name = "com.amazonaws.us-east-1.s3"
route_table_ids = concat(
aws_route_table.private[*].id,
aws_route_table.database[*].id,
aws_route_table.compute[*].id
)
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = "s3:*"
Resource = "*"
}]
})
tags = { Name = "s3-gateway-endpoint" }
}
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.production.id
service_name = "com.amazonaws.us-east-1.dynamodb"
route_table_ids = aws_route_table.private[*].id
tags = { Name = "dynamodb-gateway-endpoint" }
}
# Interface endpoints for high-traffic services
locals {
interface_endpoints = [
"ecr.api",
"ecr.dkr",
"logs",
"monitoring",
"sqs",
"sns",
"secretsmanager",
"ssm",
"ssmmessages",
"ec2messages",
"kms",
"sts"
]
}
resource "aws_vpc_endpoint" "interfaces" {
for_each = toset(local.interface_endpoints)
vpc_id = aws_vpc.production.id
service_name = "com.amazonaws.us-east-1.${each.value}"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.vpc_endpoints.id]
tags = { Name = "${each.value}-endpoint" }
}
# Security group for VPC endpoints
resource "aws_security_group" "vpc_endpoints" {
name_prefix = "vpc-endpoints-"
vpc_id = aws_vpc.production.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [aws_vpc.production.cidr_block]
}
tags = { Name = "vpc-endpoints-sg" }
}
Impact: Deploying gateway endpoints for S3 and DynamoDB saved $7,290/month immediately (free to use). Interface endpoints for ECR, CloudWatch, and SQS added $876/month in endpoint costs but eliminated $3,078/month in NAT processing — net savings of $2,202/month.
Strategy 2: NAT Instance for Low-Traffic Subnets
For subnets with minimal internet-bound traffic (< 5 GB/month), a t4g.nano NAT instance costs $3/month versus $32.85/month for a NAT Gateway — plus it has no per-GB processing charge.
import boto3
from dataclasses import dataclass
@dataclass
class NATCostComparison:
"""Compare NAT Gateway vs NAT Instance costs for a subnet."""
monthly_gb: float
@property
def nat_gateway_cost(self) -> float:
hourly = 0.045 * 730 # $32.85
processing = self.monthly_gb * 0.045
return hourly + processing
@property
def nat_instance_cost(self) -> float:
# t4g.nano: $0.0042/hr
instance = 0.0042 * 730 # $3.07
# No per-GB processing charge, just standard EC2 networking
return instance
@property
def savings(self) -> float:
return self.nat_gateway_cost - self.nat_instance_cost
@property
def savings_pct(self) -> float:
return (self.savings / self.nat_gateway_cost) * 100
def analyze_nat_optimization(subnets: list[dict]) -> dict:
"""Analyze which subnets should use NAT instances vs gateways."""
results = {'keep_gateway': [], 'switch_to_instance': [], 'total_savings': 0}
for subnet in subnets:
comparison = NATCostComparison(monthly_gb=subnet['monthly_gb'])
# NAT instances are suitable for < 50 GB/month
# Above that, throughput limitations make them risky
if subnet['monthly_gb'] < 50:
results['switch_to_instance'].append({
'subnet': subnet['name'],
'monthly_gb': subnet['monthly_gb'],
'current_cost': comparison.nat_gateway_cost,
'new_cost': comparison.nat_instance_cost,
'savings': comparison.savings
})
results['total_savings'] += comparison.savings
else:
results['keep_gateway'].append({
'subnet': subnet['name'],
'monthly_gb': subnet['monthly_gb'],
'reason': 'High throughput requirement'
})
return results
# Analyze our subnets
subnets = [
{'name': 'dev-private-a', 'monthly_gb': 12},
{'name': 'dev-private-b', 'monthly_gb': 8},
{'name': 'staging-private-a', 'monthly_gb': 25},
{'name': 'tools-private-a', 'monthly_gb': 3},
{'name': 'prod-private-a', 'monthly_gb': 2400}, # Keep gateway
{'name': 'prod-private-b', 'monthly_gb': 2200}, # Keep gateway
]
result = analyze_nat_optimization(subnets)
print(f"Total monthly savings: ${result['total_savings']:,.2f}")
Strategy 3: Egress-Optimized Architecture
For third-party API calls that genuinely need internet access, we implemented a caching proxy that reduces redundant outbound requests:
import hashlib
import json
import time
from typing import Optional
import aiohttp
import aioredis
class EgressCachingProxy:
"""Cache external API responses to reduce NAT Gateway egress."""
def __init__(self, redis_url: str, default_ttl: int = 300):
self.redis_url = redis_url
self.default_ttl = default_ttl
self._redis: Optional[aioredis.Redis] = None
self._stats = {'hits': 0, 'misses': 0, 'bytes_saved': 0}
async def connect(self) -> None:
self._redis = await aioredis.from_url(self.redis_url)
def _cache_key(self, method: str, url: str, body: Optional[str]) -> str:
"""Generate deterministic cache key."""
content = f"{method}:{url}:{body or ''}"
return f"egress_cache:{hashlib.sha256(content.encode()).hexdigest()}"
async def request(
self,
method: str,
url: str,
body: Optional[str] = None,
ttl: Optional[int] = None,
cacheable: bool = True
) -> dict:
"""Make an external request with caching."""
cache_key = self._cache_key(method, url, body)
# Check cache for GET requests
if cacheable and method.upper() == 'GET':
cached = await self._redis.get(cache_key)
if cached:
self._stats['hits'] += 1
response_data = json.loads(cached)
self._stats['bytes_saved'] += response_data.get('size', 0)
return response_data
# Cache miss — make actual request (goes through NAT Gateway)
self._stats['misses'] += 1
async with aiohttp.ClientSession() as session:
async with session.request(method, url, data=body) as response:
response_body = await response.read()
result = {
'status': response.status,
'headers': dict(response.headers),
'body': response_body.decode('utf-8', errors='replace'),
'size': len(response_body),
'cached_at': time.time()
}
# Cache successful responses
if cacheable and 200 <= response.status < 300:
await self._redis.setex(
cache_key,
ttl or self.default_ttl,
json.dumps(result)
)
return result
@property
def hit_ratio(self) -> float:
total = self._stats['hits'] + self._stats['misses']
return self._stats['hits'] / max(total, 1)
@property
def monthly_savings_estimate(self) -> float:
"""Estimate monthly NAT Gateway savings from caching."""
bytes_saved = self._stats['bytes_saved']
gb_saved = bytes_saved / (1024**3)
return gb_saved * 0.045 # NAT processing rate
Strategy 4: Split Horizon DNS for AWS Services
Some third-party services have AWS PrivateLink support that teams don't realize:
| Service | PrivateLink Available | Monthly Traffic | Savings |
|---|---|---|---|
| Datadog | Yes | 4,200 GB | $189/mo |
| MongoDB Atlas | Yes | 1,800 GB | $81/mo |
| Snowflake | Yes | 2,400 GB | $108/mo |
| Confluent Kafka | Yes | 3,600 GB | $162/mo |
Complete Cost Results
| Strategy | Monthly Savings | Implementation Effort |
|---|---|---|
| S3 + DynamoDB gateway endpoints | $7,290 | 1 day (Terraform) |
| Interface endpoints (ECR, CW, SQS) | $2,202 | 2 days |
| NAT instances (dev/staging) | $480 | 1 day |
| Egress caching proxy | $1,840 | 1 week |
| Third-party PrivateLink | $540 | 2 days |
| Total monthly savings | $12,352 | |
| Annualized | $148,224 |
Remaining NAT Gateway cost: $12,000/month (down from $54K) — all for traffic that genuinely requires internet routing.
Monitoring NAT Gateway Usage
After optimization, monitor for regression:
import boto3
from datetime import datetime, timedelta
def detect_nat_gateway_anomalies(
nat_gateway_id: str,
region: str,
threshold_gb_daily: float = 500.0
) -> dict:
"""Detect unexpected NAT Gateway traffic spikes."""
cloudwatch = boto3.client('cloudwatch', region_name=region)
response = cloudwatch.get_metric_statistics(
Namespace='AWS/NATGateway',
MetricName='BytesOutToDestination',
Dimensions=[{'Name': 'NatGatewayId', 'Value': nat_gateway_id}],
StartTime=datetime.utcnow() - timedelta(days=1),
EndTime=datetime.utcnow(),
Period=3600,
Statistics=['Sum']
)
daily_bytes = sum(dp['Sum'] for dp in response['Datapoints'])
daily_gb = daily_bytes / (1024**3)
alert = daily_gb > threshold_gb_daily
return {
'nat_gateway_id': nat_gateway_id,
'daily_gb': round(daily_gb, 2),
'daily_cost': round(daily_gb * 0.045, 2),
'alert': alert,
'message': f"NAT Gateway {nat_gateway_id}: {daily_gb:.1f} GB/day "
f"(${daily_gb * 0.045:.2f}/day)"
}
Key Takeaways
- 90%+ of NAT traffic is likely to AWS services. VPC endpoints eliminate this traffic for free or near-free.
- Gateway endpoints are literally free. There is no excuse for S3 or DynamoDB traffic routing through NAT.
- NAT instances still have a place. For low-traffic subnets (dev, staging, tools), they're 10x cheaper.
- Cache external API responses. A 60% cache hit ratio on third-party APIs reduces egress by 60%.
- Check for PrivateLink availability. Many SaaS vendors now support PrivateLink — eliminating NAT charges entirely.
NAT Gateway costs are the most fixable waste in most AWS accounts. The pattern is always the same: deploy VPC endpoints for AWS services, use NAT instances for low-traffic paths, and cache whatever external traffic you can. The savings are immediate and permanent. For the broader networking cost picture, compare VPC peering vs Transit Gateway costs to ensure you are not overpaying for internal connectivity as well.
Recommended reading

Per-Team Cost Allocation in Shared Kubernetes Clusters: From Chaos to Clarity
Implementing accurate per-namespace cost allocation in multi-tenant Kubernetes clusters, covering request vs. usage attribution, shared resource amortization, and building showback dashboards that drive accountability.

Measuring and Eliminating Toil: From 40% to 12% of Engineering Time
A systematic approach to identifying, measuring, and automating toil—the repetitive operational work that scales linearly with service growth and prevents engineers from doing creative work.

Serverless Postgres in Production: Branching, Scale-to-Zero, and the End of Database Provisioning
Running Neon serverless Postgres in production for 8 months — covering database branching workflows, scale-to-zero economics, connection pooling, and migration from RDS.

Comments
No comments yet. Be the first to share your thoughts.