Skip to main content
Cloud & AWS·8 min read

How to Slash AWS NAT Gateway Bills by 80% — Complete Cost Optimization Guide

Reduce AWS NAT Gateway data transfer costs with VPC Endpoints, localized NAT placement, S3 Gateway Endpoints, cross-AZ routing optimization, and PrivateLink strategies.

DT

DevOps Engineer & Technical Writer

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

NAT GATEWAY COST BREAKDOWN — WHERE YOUR MONEY GOES DATA PROCESSING CHARGES $0.045 per GB processed through NAT + $0.045 per GB data transfer out 1 TB/day = $2,700/month just in processing HOURLY CHARGES $0.045 per hour per NAT Gateway = $32.40/month per gateway (always on) 3 AZs = $97.20/month baseline HIDDEN COST MULTIPLIERS: 1. Cross-AZ traffic routed through NAT = $0.01/GB extra per direction 2. S3/DynamoDB calls from private subnets = unnecessary NAT processing 3. Container image pulls from ECR through NAT = massive bandwidth waste

AWS NAT Gateway charges apply in two ways:

  • Hourly charge: $0.045/hour per gateway (~$32/month per NAT)
  • Data processing charge: $0.045 per GB that flows through the NAT
  • 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"

    }

    }

    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

  • ECR (ecr.api + ecr.dkr + s3) — Container image pulls
  • CloudWatch Logs — Log shipping
  • STS — IAM role operations
  • Secrets Manager — Secret retrieval
  • SSM — Systems Manager agent traffic
  • KMS — Encryption operations
  • SQS/SNS — Message queue traffic
  • 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"

    }

    }

    CriteriaNAT InstanceNAT Gateway
    Cost (low traffic)~$3-10/month~$32/month
    BandwidthLimited by instance typeUp to 100 Gbps
    High availabilityManual (ASG needed)Built-in per AZ
    Use caseDev/stagingProduction

    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

    OptimizationEffortTypical Savings
    S3 Gateway Endpoint5 min20-40%
    DynamoDB Gateway Endpoint5 min5-10%
    ECR Interface Endpoints15 min10-20%
    CloudWatch Logs Endpoint10 min5-15%
    Per-AZ NAT placement30 min10-20%
    NAT Instance for dev1 hour90% for non-prod
    Pull-through image cache20 min5-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.

    ---