AWS charges differently for same-AZ, cross-AZ, cross-region, and internet traffic - and most teams have no idea which one is bleeding money. Here's every scenario explained with Terraform patterns to minimize each.
Ask any AWS engineer: "How much does data transfer cost?"
Watch them struggle π
That's because AWS has at least 15 different data transfer prices depending on where data goes, which service sends it, and whether you remembered to use a VPC endpoint.
Nobody memorizes this. So I built the cheat sheet I wish I had, plus Terraform patterns to fix each one.
π The Master Cheat Sheet
Bookmark this. You'll need it.
Traffic Within AWS
| From β To | Cost/GB | Notes |
|---|---|---|
| EC2 β EC2 (same AZ) | Free β | Use private IP only |
| EC2 β EC2 (same AZ, public IP) | $0.02 | Private IP = free, public IP = paid π€¦ |
| EC2 β EC2 (cross-AZ) | $0.02 | $0.01 out + $0.01 in |
| EC2 β EC2 (cross-region) | $0.02 | Varies by region pair |
| EC2 β S3 (same region) | Free β | Via VPC Gateway Endpoint |
| EC2 β S3 (same region, no endpoint) | $0.09 | Goes through NAT Gateway πΈ |
| EC2 β DynamoDB (same region) | Free β | Via VPC Gateway Endpoint |
| EC2 β RDS (same AZ) | Free β | |
| EC2 β RDS (cross-AZ) | $0.02 | Multi-AZ adds this automatically |
| S3 β CloudFront | Free β | CloudFront origin fetches are free |
| S3 β Internet | $0.09 | First 10TB/month |
| CloudFront β Internet | $0.085 | Cheaper than S3 direct + 1TB/month free |
| NAT Gateway processing | $0.045 | On top of any other transfer fees |
Traffic to Internet
| Volume/Month | S3/EC2 Direct | Via CloudFront |
|---|---|---|
| First 1TB | $0.09/GB | Free (free tier) β |
| 1-10TB | $0.09/GB | $0.085/GB |
| 10-50TB | $0.085/GB | $0.080/GB |
| 50-150TB | $0.070/GB | $0.060/GB |
CloudFront is cheaper than direct S3 at every tier. Yes, adding a CDN saves money. π€―
πΈ The 5 Biggest Data Transfer Traps
Trap #1: NAT Gateway Double Tax
This is the most expensive mistake teams make.
Private EC2 β NAT Gateway β Internet β S3
You pay:
NAT Gateway processing: $0.045/GB
Data transfer out: $0.09/GB
Total: $0.135/GB π±
Fix with VPC Endpoint:
Private EC2 β VPC Endpoint β S3
Total: $0.00/GB β
Terraform fix:
# FREE S3 access from private subnets
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 = var.private_route_table_ids
tags = { Name = "s3-gateway-endpoint" }
}
# FREE DynamoDB access from private subnets
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
tags = { Name = "dynamodb-gateway-endpoint" }
}
Savings: $0.135/GB β $0.00/GB. For 1TB/month of S3 traffic = $135/month saved.
Trap #2: Cross-AZ Traffic You Don't Know About
Multi-AZ is great for availability. But every byte between AZs costs $0.02/GB.
Common hidden cross-AZ traffic:
- App server in AZ-a talking to Redis in AZ-b
- Microservices spread across AZs calling each other
- RDS Multi-AZ synchronous replication
Terraform fix β Keep tightly coupled services in the same AZ:
# Pin your app and its cache to the same AZ
resource "aws_instance" "app" {
subnet_id = aws_subnet.private_az_a.id # AZ-a
# ...
}
resource "aws_elasticache_replication_group" "redis" {
preferred_cache_cluster_azs = ["us-east-1a"] # Same AZ
# ...
}
# For ECS β use placement constraints
resource "aws_ecs_service" "api" {
# ...
placement_constraints {
type = "memberOf"
expression = "attribute:ecs.availability-zone == us-east-1a"
}
}
β οΈ Trade-off: Pinning to one AZ reduces availability. Do this for dev/staging, not production. In prod, accept cross-AZ cost as the price of HA.
Trap #3: Public IP When Private IP Works
This one is sneaky. Two EC2 instances in the same AZ communicating via public IP costs $0.02/GB. Via private IP? Free.
EC2-A (public IP) β Internet Gateway β EC2-B (public IP)
Cost: $0.02/GB
EC2-A (private IP) β EC2-B (private IP)
Cost: $0.00/GB β
Terraform fix:
# Don't assign public IPs to instances that don't need them
resource "aws_instance" "backend" {
subnet_id = aws_subnet.private.id
associate_public_ip_address = false # π No public IP
tags = { Name = "backend-server" }
}
# Use internal ALB for service-to-service communication
resource "aws_lb" "internal" {
name = "internal-api"
internal = true # π Internal, no internet-facing
load_balancer_type = "application"
subnets = var.private_subnet_ids
}
Trap #4: S3 Direct Download Instead of CloudFront
If you're serving files to users directly from S3, you're paying more than if you put CloudFront in front.
S3 β Internet: $0.09/GB + no free tier
CloudFront β Internet: $0.085/GB + 1TB/month FREE
CloudFront is literally cheaper AND faster. There's no reason not to use it for public assets.
Terraform fix:
resource "aws_cloudfront_distribution" "assets" {
enabled = true
is_ipv6_enabled = true
origin {
domain_name = aws_s3_bucket.assets.bucket_regional_domain_name
origin_id = "s3-assets"
origin_access_control_id = aws_cloudfront_origin_access_control.s3.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "s3-assets"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies { forward = "none" }
}
# Cache for 24 hours
min_ttl = 0
default_ttl = 86400
max_ttl = 604800
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true
}
tags = { Name = "asset-cdn" }
}
resource "aws_cloudfront_origin_access_control" "s3" {
name = "s3-assets-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
Savings on 2TB/month: S3 direct = $180/mo β CloudFront = $85/mo (first 1TB free). $95/month saved. π°
Trap #5: Cross-Region Replication Without Thinking
S3 cross-region replication, RDS cross-region read replicas, DynamoDB global tables β all incur $0.02/GB transfer.
S3 Replication: us-east-1 β eu-west-1
100GB/month of changes = $2/month (seems cheap)
10TB/month of changes = $200/month (not cheap) π¬
Terraform fix β Replicate selectively:
resource "aws_s3_bucket_replication_configuration" "selective" {
bucket = aws_s3_bucket.primary.id
role = aws_iam_role.replication.arn
rule {
id = "critical-data-only"
status = "Enabled"
# Only replicate what matters, not everything
filter {
prefix = "critical/" # π Not the entire bucket
}
destination {
bucket = aws_s3_bucket.replica.arn
storage_class = "STANDARD_IA" # Cheaper storage in replica
}
}
}
π Data Transfer Cost Monitor
Deploy this to track where your data transfer money goes:
# monitoring/data-transfer.tf
resource "aws_ce_anomaly_monitor" "data_transfer" {
name = "data-transfer-anomaly-monitor"
monitor_type = "DIMENSIONAL"
monitor_dimension = "SERVICE"
}
resource "aws_ce_anomaly_subscription" "data_transfer" {
name = "data-transfer-alerts"
monitor_arn_list = [aws_ce_anomaly_monitor.data_transfer.arn]
frequency = "DAILY"
threshold_expression {
dimension {
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
values = ["50"] # Alert if anomaly > $50
match_options = ["GREATER_THAN_OR_EQUAL"]
}
}
subscriber {
type = "EMAIL"
address = var.alert_email
}
}
# CloudWatch dashboard for data transfer visibility
resource "aws_cloudwatch_dashboard" "data_transfer" {
dashboard_name = "data-transfer-costs"
dashboard_body = jsonencode({
widgets = [
{
type = "metric"
x = 0
y = 0
width = 12
height = 6
properties = {
metrics = [
["AWS/EC2", "NetworkOut", { stat = "Sum", period = 86400 }],
["AWS/EC2", "NetworkIn", { stat = "Sum", period = 86400 }]
]
title = "EC2 Network Traffic (Daily)"
region = var.region
}
},
{
type = "metric"
x = 12
y = 0
width = 12
height = 6
properties = {
metrics = [
["AWS/NATGateway", "BytesOutToDestination", { stat = "Sum", period = 86400 }],
["AWS/NATGateway", "BytesOutToSource", { stat = "Sum", period = 86400 }]
]
title = "NAT Gateway Traffic (Daily)"
region = var.region
}
}
]
})
}
β‘ Quick Audit: Where's Your Money Going?
# Check data transfer costs by service (last 30 days)
aws ce get-cost-and-usage \
--time-period Start=$(date -u -d '30 days ago' +%Y-%m-%d),End=$(date -u +%Y-%m-%d) \
--granularity MONTHLY \
--metrics BlendedCost \
--filter '{
"Dimensions": {
"Key": "USAGE_TYPE_GROUP",
"Values": ["EC2: Data Transfer - Internet (Out)",
"EC2: Data Transfer - Region to Region (Out)",
"EC2: Data Transfer - Inter AZ"]
}
}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE_GROUP \
--output table
π‘ Quick Reference: What to Fix First
| Problem | Fix | Savings | Effort |
|---|---|---|---|
| S3/DynamoDB via NAT Gateway | VPC Gateway Endpoints | $0.135/GB β Free | 5 min |
| S3 direct to internet | CloudFront in front | $0.09/GB β Free (1TB) | 20 min |
| Public IP same-AZ traffic | Use private IPs | $0.02/GB β Free | 10 min |
| All traffic via NAT Gateway | Interface Endpoints for AWS services | $0.045/GB saved | 15 min |
| Full bucket cross-region replication | Filter by prefix | 50-90% less transfer | 10 min |
| Cross-AZ microservice chatter (non-prod) | Pin to same AZ | $0.02/GB β Free | 10 min |
Start with VPC Gateway Endpoints. They're free, take 5 minutes, and eliminate the most expensive trap on this list. π―
π TL;DR
Same AZ, private IP: FREE β
(always use this)
Same AZ, public IP: $0.02 (why??)
Cross-AZ: $0.02 (Multi-AZ tax)
Cross-Region: $0.02 (replicate selectively)
To Internet: $0.09 (use CloudFront instead)
Via NAT Gateway: +$0.045 on top (use VPC Endpoints)
S3/DynamoDB via Endpoint: FREE β
(no-brainer)
S3 β CloudFront: FREE β
(origin fetch is free)
CloudFront β Internet: $0.085 + 1TB free (cheaper than S3 direct)
Bottom line: Data transfer is the most confusing line item on your AWS bill β and that's by design. Print this cheat sheet, tape it to your monitor, and stop paying for traffic that should be free. π¨οΈ
I bet you don't have VPC Gateway Endpoints for S3 yet. Go deploy them right now β it's 5 lines of Terraform and saves $0.135 on every GB. I'll wait. π
Found this helpful? Follow for more AWS cost optimization with Terraform! π¬
Top comments (2)
nice post
Thank you :)