How NAT Gateway billing works and why it surprises teams
A NAT Gateway lets instances in private subnets open outbound connections to the internet or to public AWS service endpoints without having public IP addresses themselves. Teams use them so workloads can pull packages, call APIs and reach AWS services while staying unreachable from the outside. The gateway translates addresses. AWS meters it in two ways, and other charges can apply to the same traffic.
| Charge | What triggers it | Direction |
|---|---|---|
| NAT hourly | Each gateway, for every hour it exists | n/a |
| NAT data processing | Every GB that passes through the gateway | Both directions |
| Data transfer out to internet | Bytes leaving AWS for the internet | Outbound |
| Inter-AZ transfer | Traffic from a subnet in one AZ to a NAT Gateway in another AZ | Both directions |
The processing charge surprises most teams. A 10 GB download from S3 through NAT is billed as 10 GB of processing even though it is inbound, and even though the destination is an AWS service. A byte sent from an instance in one AZ, through a NAT Gateway in another AZ, out to the internet can be billed three times: once as inter-AZ transfer, once as processing, and once as transfer out. Rates vary by region and change over time, so check the AWS pricing page for your regions before you model anything.
Some of this spend cannot be removed. Calls to third-party SaaS, responses to customers and traffic to other clouds have to leave AWS. The part you can address is traffic that never needed NAT in the first place: calls to AWS services, cross-AZ hops to a shared gateway, and container image pulls.
+----------------------------------------------------------------------+
| VPC |
| AZ-a: private subnet AZ-b: public subnet |
| +------------------+ +------------------+ |
| | Instance |----[1]---->| NAT Gateway | [H] hourly |
| | (no public IP) |<-----------| shared by AZ-a | [2] per GB |
| +------------------+ +------------------+ both ways |
| | | |
+--------------------------------------|----------|--------------------+
[3] transfer out | | [2] still billed
v v
+----------------+ +--------------------------+
| Internet | | AWS service (S3, ECR) |
| SaaS, clouds, | | did not need NAT |
| customers | | |
+----------------+ +--------------------------+
[H] NAT hourly charge, per gateway, for every hour it exists
[1] Inter-AZ transfer: instance in AZ-a to NAT Gateway in AZ-b
[2] NAT data processing: every GB through the gateway, both directions
[3] Data transfer out to the internet
One byte AZ-a -> NAT in AZ-b -> internet is billed [1] + [2] + [3].
Left path is mostly irreducible; the right path and [1] are addressable.
A single outbound byte from a private subnet can be billed as inter-AZ transfer, NAT data processing and internet transfer out, while traffic to AWS services still pays NAT processing even though it never needed the gateway.
Finding the spend in Cost Explorer and the Cost and Usage Report
Before changing routes or adding endpoints, find out which of these categories makes up your bill.
Cost Explorer: a quick first cut
NAT Gateway charges usually appear under the EC2-Other service, not under a NAT or VPC heading. Filter to that service, group by Usage Type, and look for entries containing NatGateway-Hours and NatGateway-Bytes. Inter-AZ transfer usually shows as DataTransfer-Regional-Bytes, and internet egress as DataTransfer-Out-Bytes. Each of these carries a regional prefix that varies by region, so search on the stable part of the name instead of the full string.
Separate hours from bytes
These two charges have different causes. Gateway-hours grow with the number of NAT Gateways and how long they run. If hours dominate, look at topology: one gateway per AZ in every VPC, idle environments, or duplicated VPCs. Bytes processed grow with traffic volume. If bytes dominate, the fix depends on which flows are crossing the gateway, and Flow Logs are how you find them. A single combined total hides which kind of problem you have.
The CUR in Athena
Cost Explorer can't easily break spend down by individual gateway. The Cost and Usage Report can. The query that follows filters line_item_usage_type with LIKE patterns such as '%NatGateway%' and '%DataTransfer%' instead of hardcoded regional prefixes. It groups by usage type and line_item_resource_id and sums line_item_usage_amount and line_item_unblended_cost. The result shows which gateway IDs drive the bytes cost, and that list tells you which VPCs to put Flow Logs on next.
-- Break down NAT-related spend by usage type and resource for one month.
-- Table name is a placeholder; use the table created by your CUR Athena integration.
SELECT
-- Bucket line items so gateway-hours and bytes processed are never summed together
CASE
WHEN line_item_usage_type LIKE '%NatGateway-Hours%' THEN 'nat_hours'
WHEN line_item_usage_type LIKE '%NatGateway-Bytes%' THEN 'nat_bytes_processed'
WHEN line_item_usage_type LIKE '%DataTransfer-Regional-Bytes%' THEN 'inter_az_transfer'
WHEN line_item_usage_type LIKE '%DataTransfer-Out-Bytes%' THEN 'internet_egress'
ELSE 'other_transfer'
END AS cost_category,
line_item_usage_type,
-- For NAT line items this is the NAT Gateway ARN or ID
line_item_resource_id,
SUM(line_item_usage_amount) AS usage_amount, -- hours or GB, depending on category
SUM(line_item_unblended_cost) AS unblended_cost
FROM cur_database.cur_table
WHERE line_item_line_item_type = 'Usage'
-- Match the stable part of the name; regional prefixes differ per region
AND (
line_item_usage_type LIKE '%NatGateway%'
OR line_item_usage_type LIKE '%DataTransfer%'
)
AND line_item_usage_start_date >= TIMESTAMP '2026-09-01 00:00:00'
AND line_item_usage_start_date < TIMESTAMP '2026-10-01 00:00:00'
GROUP BY 1, 2, 3
ORDER BY unblended_cost DESC
LIMIT 50;
An Athena query over the Cost and Usage Report that splits NAT Gateway hours, bytes processed, inter-AZ transfer and internet egress by usage type and resource ID, using LIKE patterns instead of region-specific prefixes.
Attributing bytes to flows with VPC Flow Logs
Once the CUR has narrowed the problem to specific gateways, the next step is to learn which workloads send those bytes and where they go. The CUR can't answer that. VPC Flow Logs can.
Create a flow log on the NAT Gateway's network interface, or on the private subnets that route through it. Use a custom format that includes pkt-srcaddr, pkt-dstaddr, bytes, srcaddr, dstaddr and flow-direction. On the NAT interface, srcaddr for outbound traffic is the gateway's own private address, so every flow looks like it came from the NAT. The pkt-* fields keep the original packet addresses, which tie each byte back to a pod, instance or task.
Deliver the logs to S3 in Parquet with hourly partitions, then query them with Athena. The query that follows groups by pkt-srcaddr and pkt-dstaddr, sums bytes, and sorts descending. Join the source addresses to your ENI or IPAM inventory to get workload names.
Destination IPs alone don't tell you much. Match them against the published AWS IP ranges file, which lists CIDR prefixes with a service and region for each. A large share landing in S3 or ECR prefixes in your own region points directly to an endpoint fix. Non-AWS destinations need a different plan.
Flow logs cost money too. You pay for ingestion and storage, and on a busy NAT that can be significant. Collect for a defined window, such as one representative week, and then disable the log or reduce it.
This work usually belongs to the platform team that owns the shared VPC, and to anyone holding an unexplained NAT line item.
-- Rank original source/destination pairs by bytes through the NAT Gateway ENI.
-- Assumes a Parquet flow log table named vpc_flow_logs with Hive-compatible
-- partitions (year, month, day, hour). In Parquet output, hyphenated field
-- names become underscores (pkt-srcaddr -> pkt_srcaddr).
-- Partition names and types depend on your table DDL. Adjust to match it.
SELECT
pkt_srcaddr, -- original sender (pod, instance, task)
pkt_dstaddr, -- original destination before/after translation
flow_direction, -- ingress/egress relative to the NAT ENI
SUM(bytes) AS total_bytes,
ROUND(SUM(bytes) / 1e9, 2) AS total_gb,
COUNT(*) AS records
FROM vpc_flow_logs
WHERE year = '2026'
AND month = '10'
AND day BETWEEN '13' AND '19' -- the time-boxed collection week
AND interface_id = 'eni-0abc123def4567890' -- placeholder NAT Gateway ENI
AND pkt_srcaddr IS NOT NULL -- skip records where the field was not populated
AND pkt_srcaddr <> '-'
GROUP BY pkt_srcaddr, pkt_dstaddr, flow_direction
ORDER BY total_bytes DESC
LIMIT 50;
-- Next steps outside this query:
-- 1. Join pkt_srcaddr to your ENI or IPAM inventory to get workload names.
-- 2. Match pkt_dstaddr against the CIDR prefixes in the AWS IP ranges file
-- to label each destination with a service and region.
An Athena query that ranks original source and destination address pairs by bytes from a time-boxed week of NAT Gateway flow logs, using the pkt_srcaddr and pkt_dstaddr fields so each byte can be traced back to a workload.
Choosing the fix: gateway endpoints, interface endpoints and AZ-local routing
Once flows are tagged by destination, each category maps to a fix with different costs and risks.
| Flow type | Fix | Recurring cost | Main risk |
|---|---|---|---|
| S3, DynamoDB | Gateway endpoint | None | Same-region only; source-VPC/endpoint policies |
| ECR, CloudWatch Logs, STS, Secrets Manager, SQS | Interface endpoint | Per-AZ hourly plus per-GB | Private DNS changes resolution VPC-wide |
| Cross-AZ to NAT | NAT per AZ, AZ-local route tables | More NAT hourly charges | Route table drift |
Gateway endpoints are a route-table entry with no hourly or processing charge. They usually give the largest single reduction because logs, backups, pipelines and artifacts are mostly S3 traffic. They only serve buckets in the same region, so cross-region traffic still goes through NAT. Bucket or endpoint policies that check the source VPC or endpoint can block access when traffic switches path. Test those policies before you roll out the endpoint.
Interface endpoints cost money per AZ, so check the volume first. Illustrative estimate, assumed rates: NAT processing at $0.045/GB, endpoint at $0.01/hour per AZ plus $0.01/GB, across 3 AZs. The fixed cost is 3 × 730 h × $0.01 = $21.90/month. Each GB saves $0.035. Break-even is about 626 GB/month per service. Enabling private DNS changes resolution for the whole VPC, which affects custom resolvers and hybrid DNS forwarding.
A single NAT Gateway adds cross-AZ charges for traffic from other AZs. Use one NAT per AZ with AZ-local route tables. After that, look at other cross-AZ paths: load balancers with cross-zone balancing, service-mesh calls that ignore locality, and database replicas.
# Terraform (HCL): S3 gateway endpoint attached to every private route table.
# Gateway endpoints add a prefix-list route to each listed table, so S3
# traffic from those subnets stops going through the NAT Gateway.
variable "region" {
type = string
default = "eu-west-1" # endpoint only serves buckets in this region
}
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
# One private route table per AZ (the same AZ-local tables that point
# 0.0.0.0/0 at that AZ's NAT Gateway). Omitting one leaves its S3 traffic on NAT.
route_table_ids = [
aws_route_table.private_a.id,
aws_route_table.private_b.id,
aws_route_table.private_c.id,
]
# Optional: scope the endpoint. If buckets use aws:SourceVpc or
# aws:SourceVpce conditions, verify they match this VPC/endpoint first.
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3:::example-logs-bucket",
"arn:aws:s3:::example-logs-bucket/*",
]
}]
})
tags = { Name = "s3-gateway-endpoint" }
}
A Terraform aws_vpc_endpoint resource that creates a same-region S3 gateway endpoint, associates it with each AZ's private route table so S3 traffic bypasses the NAT Gateway, and attaches an optional endpoint policy.
Container images, builds and other traffic that should not cross the boundary
Container image pulls are often the largest NAT line item that nobody planned for. Each node pulls its own copy of every image it runs, and the container runtime's local cache does not help a node that was just launched. An autoscaling event that adds 20 nodes running a 1 GB image moves about 20 GB through the NAT gateway (illustrative estimate). A deploy that rolls a new tag across the fleet does the same. These pulls show up in flow logs as large transfers to registry and object-storage addresses that cluster around scale-out and deploy times.
Keep image pulls off the NAT path
For ECR, create interface endpoints for both the ECR API and the Docker registry endpoint. Also add the S3 gateway endpoint. ECR serves layer blobs from S3, so if you skip the S3 endpoint, the manifests resolve privately while the bulk of the bytes still go through the NAT.
For public images, ECR pull-through cache rules fetch each upstream layer once. Later pulls come from your private registry over the same endpoints.
You can also reduce the bytes themselves:
- Use multi-stage builds and slim base images.
- Share base layers across services so nodes reuse cached layers.
- Bake common images into the node AMI so new nodes start warm.
Shrink what crosses at all
- Telemetry: Compress and batch it. Many small uncompressed exports cost more bytes and more requests.
- Third-party APIs: Cache responses from chatty calls and stop polling where webhooks exist.
- Dependencies: Mirror package and dependency downloads internally so CI builds stop fetching the same artifacts from the internet on every run.
- Outbound access: Ask whether each workload needs internet egress. Many need only AWS APIs, which endpoints can serve. Remove the default route for those workloads.
When to avoid these fixes and what the alternatives cost
Every option below swaps one cost driver for another. Check which driver dominates your bill before you pick one.
Interface endpoints below break-even. An interface endpoint is billed per hour in each AZ where it runs, plus a per-GB processing charge that is lower than NAT's. It saves money only when enough traffic flows through it.
Illustrative estimate (assumed numbers, not a benchmark): assume NAT processing costs $0.045/GB, endpoint processing costs $0.01/GB, and the endpoint costs $0.01/hour per AZ across 3 AZs for 730 hours.
Break-even GB/month = (AZs × hours × hourly rate) ÷ (NAT rate − endpoint rate) = (3 × 730 × 0.01) ÷ 0.035 ≈ 626 GB
Multiply that by every service, VPC and account, and low-volume endpoints add up to sprawl that costs more than the NAT traffic it replaced. Gateway endpoints for S3 and DynamoDB have no hourly charge, so this math does not apply to them.
| Option | Cost drivers | Operational load | Failure mode |
|---|---|---|---|
| Per-VPC interface endpoints | Hourly × AZs × VPCs, plus per-GB | Low per VPC, high in aggregate | Missing endpoint falls back to NAT silently |
| Centralized endpoints in shared VPC | Fewer endpoints, plus transit attachment-hours and per-GB | Private DNS zone sharing, routing | Shared VPC or DNS misconfiguration breaks every consumer |
| One NAT per AZ | Hourly × AZs | Low | One AZ's egress fails |
| Single shared NAT | One hourly charge, plus cross-AZ transfer | Low | Its AZ fails, and egress fails for all AZs |
| Self-managed NAT instances | Instance hours, no NAT per-GB processing | Patching, scaling, failover scripts | Instance bandwidth cap, or a host failure you must detect yourself |
Choose NAT instances deliberately, with an owner for patching and failover. Do not adopt them as a quiet cost cut that nobody maintains.
Guardrails, cost impact and engineering time
Optimizations decay because the traffic mix keeps changing. A new service ships without the endpoint its peers use. An SDK upgrade switches to a different regional hostname that your endpoint DNS does not cover. A retry storm against a third-party API multiplies egress, or a logging change sends verbose output to an external collector. None of these appear in a code review as a cost change.
Guardrails that catch drift
- Cost allocation tags on every NAT Gateway and VPC endpoint, such as
team,vpc-purposeandenvironment, activated in the billing console. Without activation they do not appear in Cost Explorer or the Cost and Usage Report. - Budgets scoped to NAT bytes usage types. Create a cost or usage budget in AWS Budgets filtered on the
NatGateway-Bytesusage type for your regions. In the console or CUR these carry a region prefix, such asUSE1-NatGateway-Bytes. Set the threshold slightly above your post-fix baseline and send notifications at forecasted and actual percentages. A separate filter onNatGateway-Hourscatches gateways that someone created and forgot. - A flow-log review tied to service launches, not to the calendar. Re-run the top-talkers query for any new service's subnets during its first week.
Effect on engineering time
When you attribute traffic first, each fix is a single targeted change. You avoid trial-and-error endpoint rollouts that break private DNS resolution or trip restrictive endpoint policies. The review stays short because the same query and the same filters run every time. Regressions surface in the week a service ships, when the author still has the context, instead of during month-end invoice analysis.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to reduce nat gateway costs, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.