This guide explains where AWS data transfer costs come from and how to bring them down, for anyone looking at a bandwidth charge nobody on the team can explain. Most of that charge comes from three meters, and the loudest is usually the NAT gateway, the managed box that lets private servers reach the internet, billing per gigabyte in both directions. The other two are cross-AZ traffic, meaning data moving between Availability Zones (the separate data centres AWS runs in one region), and egress, the data you send out to the internet. The fix is rarely sending less data, and more often routing the same bytes through a VPC endpoint, a private door from your network to S3 that skips the NAT gateway. By the end you will know which meter your money is going into and what to change first.
What’s happening
AWS meters traffic by path, not by volume alone, and the paths have wildly different rates. Traffic into AWS from the internet is free. Traffic out to the internet bills per GB, with rates that step down at volume tiers and differ by region (and a modest monthly free allowance account-wide). Traffic between AZs in the same region bills per GB in each direction — so one GB exchanged between an app server in ca-central-1a and a database in ca-central-1b is metered twice. Traffic within the same AZ over private IPs is free; the same packets sent between the same instances via their public IPs bill at regional rates.
Then there is the multiplier: a NAT Gateway charges an hourly fee plus a per-GB processing fee on everything through it, both directions, on top of whatever the traffic costs on the far side. The pattern that hurts: private-subnet instances pulling from S3 or ECR through a NAT Gateway. The S3 transfer inside the region would be free — but every byte pays the NAT processing toll. Whether the NAT construct itself is right for your scale is a separate question, covered in NAT Gateway vs NAT instance; this article is about finding which of these meters is running on your bill.
What to check first
- Cost Explorer, grouped by usage type, filtered to service EC2 and VPC: the lines to recognize are
NatGateway-Bytes(processing),DataTransfer-Regional-Bytes(cross-AZ/public-IP regional), andDataTransfer-Out-Bytes(internet egress). - Whether gateway endpoints exist. VPC > Endpoints — no S3/DynamoDB gateway endpoint plus a NAT Gateway is the single most common finding.
- Where your chatty pairs live. App-to-database, app-to-cache, service-to-service: same AZ or crossing one?
- Anything talking to a regional service via public hostname from a public subnet by public IP — the free private path only applies when traffic actually rides private addressing.
- Cross-region replication and multi-region reads, which bill at inter-region rates and are easy to forget after a DR project.
How to verify
# Which transfer usage types are costing what, last full month.
# The dates are computed, so this stays correct whenever you run it.
# GNU date (Amazon Linux, Ubuntu). On macOS use: date -u -v-1m +%Y-%m-01
MONTH_START=$(date -u -d "$(date -u +%Y-%m-01) -1 month" +%Y-%m-01)
MONTH_END=$(date -u +%Y-%m-01)
aws ce get-cost-and-usage \
--time-period Start=$MONTH_START,End=$MONTH_END \
--granularity MONTHLY \
--metrics UnblendedCost \
--group-by Type=DIMENSION,Key=USAGE_TYPE \
--filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon Elastic Compute Cloud - Compute","Amazon Virtual Private Cloud"]}}'
# How much each NAT Gateway is processing, over the last 7 days
WINDOW_START=$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)
WINDOW_END=$(date -u +%Y-%m-%dT%H:%M:%SZ)
aws cloudwatch get-metric-statistics --namespace AWS/NATGateway \
--metric-name BytesOutToDestination \
--dimensions Name=NatGatewayId,Value=nat-0123456789abcdef0 \
--start-time $WINDOW_START --end-time $WINDOW_END \
--period 86400 --statistics Sum
To see who is behind the NAT traffic, enable VPC Flow Logs and query top talkers in CloudWatch Logs Insights:
filter dstAddr like /^10\.0\.0\./
| stats sum(bytes) as totalBytes by srcAddr
| sort totalBytes desc
| limit 20
(Replace the prefix with your NAT Gateway’s private IP.) The console path for the endpoint check is VPC > Endpoints > Create endpoint, service name com.amazonaws.<region>.s3, type Gateway. Full path-by-path pricing mechanics are in the VPC documentation.
The fix
- Add gateway endpoints for S3 and DynamoDB. They are free, take minutes, and remove that traffic from the NAT path entirely — route table entries do the redirection. For accounts pulling container images or S3 data from private subnets, this is routinely the largest single cut. Interface endpoints for other services (ECR, CloudWatch, SSM) bill hourly plus per-GB but still undercut NAT processing at moderate volume — the trade-offs are in VPC endpoints for S3 and DynamoDB.
- Co-locate the chatty pairs. Put the app tier and its cache in the same AZ; keep read-heavy services reading from a same-AZ database replica where the engine supports it. Do not collapse a Multi-AZ architecture to save transfer — place conversations, not availability, in one AZ.
- Check that intra-region traffic uses private addressing. Connections to a peer’s public IP or a public hostname that resolves outside the VPC bill as regional transfer. Private DNS on endpoints and VPC peering/Transit Gateway routing keep it on the free or cheap path.
- Front internet egress with CloudFront where the content repeats. Origin-to-CloudFront transfer is free; CloudFront egress rates and cache hits both beat raw per-GB egress for static and semi-static content.
- Compress and batch what must cross a meter. gzip on API responses, compressed replication streams, aggregated log shipping. Boring, effective, and it stacks with everything above.
- Revisit one NAT Gateway per AZ versus per VPC. One shared NAT saves hourly fees but adds cross-AZ processing for the other AZs’ traffic; per-AZ NATs cost more hours but keep traffic local. High-volume accounts usually win with per-AZ.
When this is the wrong move
Do not chase transfer pennies into architectural debt: collapsing AZs, removing replicas, or turning off cross-region DR replication saves transfer and sells resilience. If the bandwidth line is dominated by genuine product traffic — customers downloading what you sell them — that is cost of goods, and the work is CDN economics, not elimination. And below a few hundred dollars a month of transfer, endpoint sprawl and routing complexity can cost more in engineering time than they return.
Common mistakes
- Blaming “S3 costs” for what is actually NAT processing on the way to S3.
- Adding an interface endpoint where a free gateway endpoint exists (S3, DynamoDB) and paying hourly for it.
- Measuring only
DataTransfer-Outand missing that regional-bytes lines are metered in both directions. - Testing from a public subnet, seeing no NAT charges, and concluding production (in private subnets) behaves the same.
Keeping it under control
- A CloudWatch alarm on NAT Gateway bytes catches new chatty workloads within days instead of at invoice time.
- Gateway endpoints in every VPC template by default — they cost nothing and prevent the classic regression.
- Tag-based cost allocation so transfer lines attribute to teams; anonymous bandwidth never gets fixed.
- Review the transfer usage types quarterly; new services and new AZs quietly add paths.
- Put “which meter does this cross?” into design review for any service-to-service integration.
Bandwidth charges are the clearest example of AWS billing the path rather than the work — once you can name the three meters, the bill stops being mysterious. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.