How does NAT gateway pricing work, and why does the data processing charge dominate?
A NAT gateway lets instances in private subnets open outbound connections to the internet or to public AWS service endpoints without being reachable from outside. Workloads that pull container images, call AWS APIs, or reach third-party services rely on it. You normally run one per Availability Zone so that a zone failure doesn't cut off egress for the other zones.
AWS bills a NAT gateway on two meters. The first is an hourly charge for each gateway that exists, whether or not it carries traffic. The second is a per-GB processing charge on every byte that passes through it, in either direction. Standard data transfer charges, such as internet egress or cross-AZ transfer, are billed on top of the processing charge. Hourly cost is fixed by your topology. Processing cost grows with throughput, and S3 data pipelines, image pulls, log shipping, and backups generate a lot of throughput.
Worked example (illustrative estimate)
Assumptions: $0.045 per gateway-hour, $0.045 per GB processed, three gateways, 730 hours per month, and 20 TB (20,480 GB) of monthly traffic, mostly S3 reads and ECR pulls. Check current regional pricing before using these numbers.
| Line item | Driver | Monthly cost |
|---|---|---|
| Gateway hours | 3 × 730 h × $0.045 | ~$99 |
| Data processing | 20,480 GB × $0.045 | ~$922 |
| Total | ~$1,021 |
In this example, processing makes up about 90% of the NAT bill. Adding or removing a gateway barely changes the total.
This section matters most to teams whose Cost Explorer shows the NatGateway-Bytes usage type rising with traffic while NatGateway-Hours stays flat. That pattern means the bill is driven by traffic, and the fix is to change where the traffic is routed.
Who should use VPC endpoints and egress routing to cut NAT costs?
This approach is for teams that run workloads in private subnets behind NAT gateways and whose NAT bill is driven by bytes processed rather than gateway hours. Typical candidates:
- Data-heavy workloads that read from or write to S3 or DynamoDB from private subnets, such as ETL jobs, analytics pipelines, backups, and log archiving.
- Container platforms (ECS, EKS, or self-managed Kubernetes) whose nodes pull images from ECR, especially clusters that scale out often or redeploy frequently.
- Platform and infrastructure teams that own shared VPC networking and want a repeatable pattern that product teams inherit without changing application code.
- Security-minded teams that also want to keep AWS-bound traffic off the public internet path and restrict which buckets or resources can be reached from the VPC.
It is less useful in a few cases. If your NAT gateways carry little traffic, the hourly charge dominates and endpoints won't change much. If most of your egress goes to third-party APIs, endpoints don't apply, and the levers are topology and traffic shaping, covered later in this article. If your workloads already run in public subnets or have no NAT gateway, there is nothing to reroute.
How does endpoint-based egress routing work?
Traffic leaving a private subnet follows that subnet's route table. In a typical setup, the only outbound route is 0.0.0.0/0 pointing at a NAT gateway, so every packet to a public address, including AWS service endpoints, goes through NAT and incurs the processing charge.
VPC endpoints give AWS-bound traffic a different path, using one of two mechanisms:
- Gateway endpoints (S3 and DynamoDB) work at the routing layer. Associating one with a route table adds a route whose destination is an AWS-managed prefix list for the service in that region. Traffic to those addresses matches that route instead of the default route and never reaches the NAT gateway.
- Interface endpoints (most other services) work at the DNS layer. AWS places network interfaces with private IPs in your subnets. With private DNS enabled, the service's standard hostname resolves to those private IPs inside the VPC. The traffic is then local to the VPC and doesn't match the NAT route at all.
Everything that doesn't match an endpoint, such as third-party APIs and cross-region AWS endpoints, keeps using the NAT gateway as before. The overall method is a loop. Measure what crosses the NAT, move the large AWS-bound flows onto endpoints where the economics work, shape or relocate the remaining external traffic, and then measure again. The sections below follow that order.
How do you find out what traffic is actually going through your NAT gateway?
Enable VPC Flow Logs on the elastic network interface that belongs to the NAT gateway, not on the whole VPC. That keeps the log volume limited to traffic that actually incurs the per-GB processing charge. Send the logs to S3 if you plan to query them with Athena, or to CloudWatch Logs for smaller environments where Logs Insights is enough.
On the NAT interface, each flow shows up twice: once between the private workload and the NAT, and once between the NAT and the internet. Aggregate the leg where one side is a private VPC address and the other is public. Group by the public address and sum bytes in both directions. Response bytes count toward the processing charge, so large downloads matter as much as uploads.
Next, join the top destinations against AWS's published ip-ranges.json. Its service field cleanly identifies S3 and DynamoDB. Many other services, such as STS and CloudWatch, appear only under the generic AMAZON tag. Container image pulls from ECR often resolve to S3 addresses for the layer data. For ambiguous ranges, cross-check with Route 53 Resolver query logs or with the hostnames your applications call. Whatever doesn't match AWS ranges is third-party API traffic.
Flow logs have costs of their own: delivery, storage, and scan charges per query. Partition by date and set lifecycle rules. A capture window that is too short will miss nightly backups, weekly batch jobs, or deploy-day image pulls, so capture across at least one full business cycle.
The result is a ranked list of destinations by cost. That list tells you which one or two endpoints are worth building first, so you don't spend effort on endpoints for low-traffic services.
-- Athena: rank public destinations behind one NAT gateway by bytes processed.
-- Assumes a table named vpc_flow_logs over the default flow log format,
-- partitioned by a string column dt ('YYYY-MM-DD'). Placeholder values:
-- VPC CIDR 10.20.0.0/16
-- NAT ENI eni-0123456789abcdef0
-- NAT private 10.20.0.15
WITH workload_legs AS (
SELECT
-- Keep the public side of the flow so both directions group together
CASE WHEN srcaddr LIKE '10.20.%' THEN dstaddr ELSE srcaddr END AS public_ip,
bytes
FROM vpc_flow_logs
WHERE interface_id = 'eni-0123456789abcdef0'
-- Span a full business cycle so weekly jobs and deploy days show up
AND dt BETWEEN '2026-10-01' AND '2026-10-14'
AND action = 'ACCEPT'
-- Drop the NAT-to-internet leg so each transfer is counted once
AND srcaddr <> '10.20.0.15'
AND dstaddr <> '10.20.0.15'
-- Exactly one side is inside the VPC, the other is public
AND (srcaddr LIKE '10.20.%') <> (dstaddr LIKE '10.20.%')
)
SELECT
public_ip,
-- Uploads and response bytes both incur the per-GB processing charge
ROUND(SUM(bytes) / 1e9, 2) AS gb_processed,
COUNT(*) AS flow_records
FROM workload_legs
GROUP BY public_ip
ORDER BY gb_processed DESC
LIMIT 50;
-- Next step: match public_ip against ip-ranges.json prefixes. The S3 and
-- DYNAMODB service tags identify gateway endpoint candidates. Addresses
-- tagged only AMAZON need hostname checks, and anything unmatched is
-- third-party traffic.
An Athena query that sums the bytes for each public destination on the NAT gateway's interface over a two-week partition range, counts each transfer once by excluding the NAT's own internet leg, and produces a ranked list to match against ip-ranges.json.
How do S3 and DynamoDB gateway endpoints remove NAT traffic for free?
Start at the top of the ranked list and ignore services that move only a few gigabytes a month. For most teams, S3 sits at or near the top of that list, and S3 is the easiest destination to fix.
A gateway endpoint is a routing construct, not a network interface. When you create one for S3 or DynamoDB and associate it with route tables, AWS adds a route to each of those tables. The destination is an AWS-managed prefix list (pl-…) that covers the service's public IP ranges in that region, and the target is the endpoint (vpce-…). Because the prefix-list route is more specific than 0.0.0.0/0, traffic to S3 follows it and bypasses the NAT gateway. Gateway endpoints have no hourly charge and no per-GB charge. That makes them the first fix for log shipping, build artifacts, backups and data pipelines, which are often the largest byte movers in a VPC.
With the CLI, you run aws ec2 create-vpc-endpoint with --vpc-endpoint-type Gateway, --service-name com.amazonaws.<region>.s3 and the private subnets' --route-table-ids. In Terraform or CloudFormation, you set the same three properties on the endpoint resource. Adding the route can reset long-lived TCP connections to S3, so apply it during a quiet window.
The prefix list covers only the endpoint's own region. A bucket in another region still resolves to IPs outside that list, so requests to it keep going through NAT.
To verify the change, run aws ec2 describe-route-tables and confirm the pl-… → vpce-… route appears in each table you associated. Then rerun your flow-log query against the NAT gateway's network interface. The S3 destinations should drop out of the ranked list, apart from any cross-region buckets.
#!/usr/bin/env bash
set -euo pipefail
REGION="us-east-1"
VPC_ID="vpc-0abc1234def567890"
RT_IDS="rtb-0aaa1111bbbb22222 rtb-0ccc3333dddd44444"
ENDPOINT_ID=$(aws ec2 create-vpc-endpoint \
--region "$REGION" \
--vpc-id "$VPC_ID" \
--vpc-endpoint-type Gateway \
--service-name "com.amazonaws.${REGION}.s3" \
--route-table-ids $RT_IDS \
--query 'VpcEndpoint.VpcEndpointId' --output text)
echo "Created $ENDPOINT_ID"
PL_ID=$(aws ec2 describe-prefix-lists \
--region "$REGION" \
--filters "Name=prefix-list-name,Values=com.amazonaws.${REGION}.s3" \
--query 'PrefixLists[0].PrefixListId' --output text)
aws ec2 describe-route-tables \
--region "$REGION" \
--route-table-ids $RT_IDS \
--query "RouteTables[].{Table:RouteTableId,Routes:Routes[?DestinationPrefixListId=='${PL_ID}'].[DestinationPrefixListId,GatewayId]}" \
--output table
Creates a same-region S3 gateway endpoint on the private route tables with the AWS CLI, then confirms each table now has a prefix-list route pointing at the endpoint.
When do interface endpoints pay off, and when should you avoid them?
Interface endpoints are elastic network interfaces placed in your subnets. Unlike gateway endpoints, AWS bills them per hour in every AZ where you deploy them, plus a per-GB processing charge. That makes them a trade rather than a free win, and the trade only pays off above a certain traffic volume.
For one endpoint, the monthly break-even volume is:
GB/month = (hourly rate × 730 × AZ count) ÷ (NAT per-GB rate − endpoint per-GB rate)
Illustrative estimate. Assume an endpoint costs $0.01 per AZ-hour and $0.01/GB, and NAT processing costs $0.045/GB. Across three AZs, the fixed cost is $21.90/month. The per-GB saving is $0.035, so break-even is about 626 GB/month for each service. Check your region's current prices before relying on these figures.
| Service | Typical NAT volume | Likely verdict |
|---|---|---|
| CloudWatch Logs | High on chatty workloads | Often pays off |
| SQS | Varies with message size and rate | Measure first |
| STS | Tiny | Rarely pays off on cost |
| Secrets Manager | Tiny | Rarely pays off on cost |
| ECR (api + dkr) | Manifests and auth only | See below |
Container image pulls are the common hidden cost, but the bytes are not where most people expect them. ECR serves image layers from S3, so the S3 gateway endpoint already removes most pull traffic from the NAT for free. The ecr.api and ecr.dkr endpoints, which are billed per AZ, only remove the NAT from the pull path entirely.
Skip interface endpoints for low-traffic services, deployments with many AZs, and setups with many VPCs. In those cases, the fixed hourly charges multiply and exceed the NAT processing they replace.
What do private DNS and endpoint policies change for security and existing workloads?
Enabling private DNS on an interface endpoint makes the service's default public hostname resolve to the endpoint's private IPs inside the VPC. The VPC must have DNS support and DNS hostnames turned on. Because SDKs and CLIs connect to that same hostname, existing workloads switch to the endpoint without code or configuration changes. That transparency is also the risk: resolution changes for every client in the VPC at once, not only the service you were targeting.
Where private DNS breaks things
Private DNS is implemented as a managed private hosted zone answered by the Amazon-provided VPC resolver. If your VPC uses custom DNS servers through DHCP options, and those servers forward to on-premises or public resolvers instead of the VPC resolver, instances keep getting public IPs and traffic keeps flowing through NAT. Hybrid setups have the opposite problem. On-premises clients that resolve through Resolver inbound endpoints may start receiving private IPs they can't route to. Map your DNS forwarding paths before you enable private DNS.
Endpoint policies
An endpoint policy is a resource policy attached to the endpoint. The default policy allows full access. A common restriction allows S3 actions only on named bucket ARNs, which blocks data exfiltration to buckets outside your control through that endpoint.
The reverse control, bucket policies that deny requests where the aws:SourceVpce condition key doesn't match your endpoint, is where outages happen. Console sessions, other accounts and third-party services never pass through your endpoint, so these requests fail with access denied errors. Run new policies in a non-production account and check CloudTrail for denials before you enforce them.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowOnlyApprovedBuckets",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::example-app-data",
"arn:aws:s3:::example-app-data/*",
"arn:aws:s3:::example-app-logs",
"arn:aws:s3:::example-app-logs/*"
]
}
]
}
An S3 endpoint policy allowing only listed actions on two named buckets; requests through this endpoint to any other bucket are denied, limiting exfiltration paths.
What are the alternatives for NAT topology and external API egress?
Once endpoints have absorbed the AWS-bound traffic, what remains is the NAT topology itself. The choice is mostly a trade between hourly charges, cross-AZ data transfer and how far a single failure can spread.
| Topology | Fixed cost | Per-GB cost | Failure blast radius |
|---|---|---|---|
| One NAT gateway per AZ | One hourly charge per AZ | NAT processing only | One AZ |
| Single shared NAT gateway | One hourly charge | NAT processing plus inter-AZ transfer from other AZs | Every AZ loses egress if the NAT's AZ fails |
| Centralized egress VPC via Transit Gateway | Fewer NAT gateways, but one Transit Gateway attachment per VPC | Transit Gateway processing plus NAT processing | Shared across all attached VPCs |
A shared NAT looks cheaper until you account for inter-AZ transfer. That charge applies to every byte a private subnet in another AZ sends through it, and those AZs depend on an AZ they don't run in. Centralized egress consolidates gateways across many VPCs. However, each attachment carries an hourly charge, and traffic is processed twice, once by Transit Gateway and once by NAT. With only a few VPCs, those charges can cancel the consolidation savings.
Calls to payment processors, SaaS platforms and LLM providers still need NAT or another egress path, because no endpoint covers them. For that traffic, you control volume and placement:
- Compress payloads where the provider accepts compressed request bodies. LLM prompts and JSON compress well.
- Batch small calls. Fewer, larger requests cut per-request overhead and retries.
- Cache responses that don't change often, such as exchange rates, model lists and catalog data.
- Place chatty integrations deliberately. Run them in the AZ that has the NAT gateway, or consolidate them into one service, so their traffic doesn't also cross AZ boundaries.
How can this approach save engineering time?
The direct benefit is a smaller bill, but the approach also cuts recurring engineering work if you set it up deliberately:
- Shorter cost investigations. Without flow-log data, a NAT cost spike usually leads to guesswork across teams about which workload caused it. A saved query and a ranked destination list turn that into a lookup, so whoever is on call can identify the source without a round of meetings.
- No application changes. Gateway endpoints work through route tables, and interface endpoints with private DNS work through the hostnames SDKs already use. Product teams don't need to change code, configuration or deployments, so the platform team can make the change without coordinating releases across services.
- Less wasted effort on low-value endpoints. Measuring first and applying the break-even formula keeps teams from building, reviewing and maintaining endpoints that never pay for themselves. Every endpoint you skip is one fewer security group, policy and DNS dependency to debug later.
- Repeatable rollout. A standard endpoint module turns each new endpoint into a small, reviewable infrastructure-as-code change instead of a one-off networking task that needs specialist knowledge.
- Fewer surprise regressions. A scheduled report on top NAT destinations catches new traffic early, before it shows up as an unexplained line item that someone has to investigate after the fact.
- Fewer outage investigations. Testing endpoint and bucket policies in a non-production account and checking CloudTrail for denials before enforcement avoids access-denied incidents that are slow to diagnose because the failing request never touches the endpoint.
A practical rollout order to reduce NAT gateway costs
Treat this as a loop, not a one-off project. The order matters because each step depends on data from the one before it.
- Measure. Enable VPC flow logs on the NAT gateway's network interface. Aggregate bytes by destination, then map those destinations to AWS services and external APIs. This tells you where the spend actually goes, not where you assume it goes.
- Add the free gateway endpoints. S3 and DynamoDB gateway endpoints cost nothing and need only route table changes. Do these first, because they often remove the largest share of traffic with the least risk.
- Add interface endpoints selectively. Add one only for services whose measured traffic exceeds the break-even point from section 4. Every endpoint is one more resource in infrastructure-as-code, one more security group to maintain, and one more thing to explain during incident review. An endpoint that saves a few dollars a month can cost more in engineering time than it saves.
- Re-measure. Compare flow logs before and after. In Cost Explorer, group by usage type and filter to the NAT gateway bytes-processed usage type. This confirms the drop actually happened.
- Repeat each quarter or after major architecture changes.
Coverage decays quietly. When a team adopts a new AWS service, its traffic goes through NAT by default, and nothing alerts you. Two lightweight guardrails catch this cheaply:
- A scheduled flow log report. It lists the top NAT destinations and flags any new entries.
- A standard endpoint module. It bundles the endpoint, its security group, its policy, and private DNS settings, so adding an endpoint takes minutes and later reviews are routine.
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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.