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.

#aws#nat-gateway#egress#cost-optimization
Cover image for the article: NAT Gateway Alternatives That Saved $42K/Month in Egress Costs

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:

  1. Hourly charge: $0.045/hour (~$32.85/month per AZ)
  2. 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 TypeMonthly GBProcessing CostCould Be Eliminated?
S3 API calls128,000 GB$5,760Yes (gateway endpoint)
ECR image pulls42,000 GB$1,890Yes (interface endpoint)
CloudWatch/Logs18,000 GB$810Yes (interface endpoint)
SQS/SNS8,400 GB$378Yes (interface endpoint)
DynamoDB34,000 GB$1,530Yes (gateway endpoint)
Third-party APIs12,000 GB$540No (needs internet)
Software updates6,000 GB$270Partial (proxy/cache)
Total248,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 &#x3C; 50 GB/month
        # Above that, throughput limitations make them risky
        if subnet['monthly_gb'] &#x3C; 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 &#x3C;= response.status &#x3C; 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:

ServicePrivateLink AvailableMonthly TrafficSavings
DatadogYes4,200 GB$189/mo
MongoDB AtlasYes1,800 GB$81/mo
SnowflakeYes2,400 GB$108/mo
Confluent KafkaYes3,600 GB$162/mo

Complete Cost Results

StrategyMonthly SavingsImplementation Effort
S3 + DynamoDB gateway endpoints$7,2901 day (Terraform)
Interface endpoints (ECR, CW, SQS)$2,2022 days
NAT instances (dev/staging)$4801 day
Egress caching proxy$1,8401 week
Third-party PrivateLink$5402 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

  1. 90%+ of NAT traffic is likely to AWS services. VPC endpoints eliminate this traffic for free or near-free.
  2. Gateway endpoints are literally free. There is no excuse for S3 or DynamoDB traffic routing through NAT.
  3. NAT instances still have a place. For low-traffic subnets (dev, staging, tools), they're 10x cheaper.
  4. Cache external API responses. A 60% cache hit ratio on third-party APIs reduces egress by 60%.
  5. 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.

Comments

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