How NAT gateway billing works and why EKS clusters run it up
A NAT gateway bills on two meters. There is an hourly charge for each gateway whether or not it carries traffic, and a per-GB data processing charge on every byte it handles, in both directions. A container image pulled through NAT is billed as processed data, the same as an outbound upload. Standard data transfer is charged on top of processing: internet egress for bytes leaving AWS, and cross-AZ transfer when a node in one Availability Zone routes through a gateway in another. Rates differ by region and change over time, so check current pricing for your regions before relying on any estimate.
EKS clusters in private subnets send almost all AWS-bound traffic through NAT by default. Much of that traffic is background activity that nobody provisioned on purpose:
| Traffic source | When it spikes | Typical destination | Must it reach the internet? |
|---|---|---|---|
| Image pulls | Node scale-up, pods scheduled without cached images | ECR (layers served from S3) | No |
| Logging and metrics DaemonSets | Continuously, scales with node count | CloudWatch, S3 | No |
| IRSA / Pod Identity credential calls | Pod start, token refresh | STS | No |
| Controllers polling AWS APIs | Continuously | EC2, ELB, Auto Scaling APIs | No |
| Application data | Workload-dependent | S3, DynamoDB | No |
The volume varies by source. STS calls are small but constant. Image pulls and S3 traffic can be large. The pattern is the same in every row: the destination is an AWS service, and the traffic still pays NAT processing.
The rest of this article assumes most of this traffic never needed to leave AWS and asks how much of it you can move off NAT. If your team runs EKS in private subnets and NAT is a visible line item on the bill, this applies to you.
Measure first: attributing NAT spend by destination
Before creating any endpoint, find out which destinations account for the bytes. Endpoints are priced per service and per AZ, so guessing wrong means paying for infrastructure that moves little traffic.
Start with billing data
In Cost Explorer or the Cost and Usage Report, filter on the NAT gateway usage types. Usage types ending in NatGateway-Bytes show data processed, and those ending in NatGateway-Hours show the fixed hourly charge. If bytes dominate, endpoints and traffic reduction can help. If hours dominate, the problem is the number of gateways, and endpoints will not change much.
Attribute bytes to destinations
Billing data does not show where traffic went. To get that, enable VPC Flow Logs on the NAT gateway's network interface. Use a custom format that includes pkt-srcaddr and pkt-dstaddr so you can see the originating node or pod as well as the remote address. Aggregate bytes by destination and keep the top entries.
Then match those destination IPs against AWS's published ip-ranges.json. Some services have their own entries, notably S3 and DynamoDB. ECR image layers are served from S3, so they appear as S3 traffic. Many other services, including STS and CloudWatch Logs, are grouped under the generic AMAZON tag. For those, use reverse DNS or the endpoint hostnames your workloads resolve. Any address not in the file is external, such as public container registries or SaaS APIs, and no VPC endpoint can serve it.
Keep collection cheap
Flow Logs and CUR queries cost money and engineering time. A time-boxed window of a few days that includes normal deploy activity is usually enough. In most clusters, a small number of destinations account for most of the bytes, and those are the only ones worth an endpoint decision.
#!/usr/bin/env python3
# Usage: python3 -I top_nat_talkers.py nat-flowlogs.txt ip-ranges.json
# Assumes a Flow Log custom format of: ${pkt-srcaddr} ${pkt-dstaddr} ${bytes}
import ipaddress, json, sys
from collections import Counter
VPC_CIDR = ipaddress.ip_network("10.0.0.0/16") # placeholder: your VPC CIDR
TOP_N = 20
def remote(src, dst):
# NAT bills both directions, so count bytes against the end outside the VPC
return dst if ipaddress.ip_address(src) in VPC_CIDR else src
bytes_by_remote = Counter()
with open(sys.argv[1]) as f:
for line in f:
parts = line.split()
if len(parts) != 3 or not parts[2].isdigit():
continue # skip header and NODATA/SKIPDATA records
src, dst, nbytes = parts
bytes_by_remote[remote(src, dst)] += int(nbytes)
with open(sys.argv[2]) as f:
prefixes = [(ipaddress.ip_network(p["ip_prefix"]), p["service"], p["region"])
for p in json.load(f)["prefixes"]]
def classify(ip):
addr = ipaddress.ip_address(ip)
matches = [(s, r) for net, s, r in prefixes if addr in net]
if not matches:
return "EXTERNAL (no VPC endpoint possible)"
# AMAZON overlaps every AWS range; prefer a specific tag such as S3
specific = [m for m in matches if m[0] != "AMAZON"]
service, region = (specific or matches)[0]
return f"{service} ({region})"
total = sum(bytes_by_remote.values()) or 1
for ip, b in bytes_by_remote.most_common(TOP_N):
print(f"{ip:<16} {b / 1e9:8.2f} GB {100 * b / total:5.1f}% {classify(ip)}")
A Python script that totals NAT gateway Flow Log bytes by remote address and labels the top talkers using the service tags in ip-ranges.json, so you can see which destinations are worth an endpoint decision.
Gateway endpoints vs interface endpoints: how each works
Gateway and interface endpoints both keep traffic to AWS services off the NAT gateway, but they work differently and are billed differently.
Gateway endpoints
A gateway endpoint adds a route to your chosen route tables. The route's destination is the service's managed prefix list, and its target is the endpoint. Traffic to S3 or DynamoDB then leaves the subnet through that route instead of the NAT gateway. There is no hourly or per-GB charge, and no DNS or security group changes are needed. That makes it the safest first change to make.
ECR stores image layers in S3, so most of the bytes in an image pull go to S3, not to ECR. If you add ECR interface endpoints without the S3 gateway endpoint, those layer downloads still go through NAT.
Interface endpoints
An interface endpoint (PrivateLink) places an elastic network interface in each subnet you select. You pay per endpoint, per AZ, per hour, plus a per-GB processing charge.
- Private DNS makes the service's normal public hostname resolve to the endpoint's private IPs, so SDKs and the kubelet use the endpoint without any configuration changes. It requires DNS support and DNS hostnames to be enabled on the VPC.
- The endpoint's security group must allow inbound TCP 443 from node and pod CIDRs.
A common EKS set:
ecr.apiandecr.dkrfor image pullsstsfor IRSAlogsfor log shippingec2for node and CNI callselasticloadbalancingfor the load balancer controller
In private-only clusters, nodes need some of these just to join the cluster and pull system images.
| Gateway | Interface | |
|---|---|---|
| Services | S3, DynamoDB | Most AWS services |
| Mechanism | Route table entry | ENI + private DNS |
| Cost | None | Hourly per AZ + per GB |
| Scope | Same region only | Same region |
Gateway endpoints only cover S3 and DynamoDB in the same region. Cross-region S3 traffic and any non-AWS destination still go through NAT.
#!/usr/bin/env bash
set -euo pipefail
# Placeholder IDs: replace with your VPC, private route tables and subnets.
REGION="us-east-1"
VPC_ID="vpc-0123456789abcdef0"
PRIVATE_RTBS="rtb-0aaa1111bbbb2222c rtb-0ddd3333eeee4444f"
PRIVATE_SUBNETS="subnet-0a1b2c3d4e5f60001 subnet-0a1b2c3d4e5f60002"
VPC_CIDR="10.0.0.0/16"
# 1) S3 gateway endpoint: adds a prefix-list route to each route table.
# No hourly or per-GB charge. ECR image layers are fetched from S3 through it.
aws ec2 create-vpc-endpoint \
--region "$REGION" \
--vpc-id "$VPC_ID" \
--vpc-endpoint-type Gateway \
--service-name "com.amazonaws.${REGION}.s3" \
--route-table-ids $PRIVATE_RTBS
# 2) Security group for interface endpoints: allow HTTPS from nodes and pods.
ENDPOINT_SG=$(aws ec2 create-security-group \
--region "$REGION" \
--vpc-id "$VPC_ID" \
--group-name vpce-https \
--description "HTTPS from VPC to interface endpoints" \
--query GroupId --output text)
aws ec2 authorize-security-group-ingress \
--region "$REGION" --group-id "$ENDPOINT_SG" \
--protocol tcp --port 443 --cidr "$VPC_CIDR"
# 3) ECR Docker registry interface endpoint: one ENI per subnet (billed per AZ-hour + per GB).
# Private DNS makes the normal registry hostname resolve to the ENIs' private IPs.
aws ec2 create-vpc-endpoint \
--region "$REGION" \
--vpc-id "$VPC_ID" \
--vpc-endpoint-type Interface \
--service-name "com.amazonaws.${REGION}.ecr.dkr" \
--subnet-ids $PRIVATE_SUBNETS \
--security-group-ids "$ENDPOINT_SG" \
--private-dns-enabled
AWS CLI commands that create an S3 gateway endpoint on the private route tables and an ecr.dkr interface endpoint with private DNS and a security group allowing HTTPS from the VPC CIDR.
Break-even math: when an interface endpoint pays for itself
Illustrative estimate. The rates below are hypothetical round numbers chosen to make the arithmetic easy to follow. They are not quoted prices.
Assumed inputs:
- NAT data processing: $0.045 per GB
- Interface endpoint: $0.01 per hour per AZ, plus $0.01 per GB processed
- 3 AZs, 730 hours per month
An endpoint pays off when:
monthly GB saved × (NAT per-GB − endpoint per-GB) > endpoint hourly × hours per month × AZs
The right side is a fixed cost that you pay whether traffic flows or not: 0.01 × 730 × 3 = $21.90 per month. Each GB moved off the NAT gateway saves 0.045 − 0.01 = $0.035. The break-even point is therefore 21.90 ÷ 0.035 ≈ 626 GB per month, per service endpoint.
| Service (assumed volume) | GB/month | Per-GB savings | Endpoint fixed cost | Net monthly |
|---|---|---|---|---|
| ECR image pulls | 2,000 | $70.00 | $21.90 | +$48.10 |
| CloudWatch Logs | 300 | $10.50 | $21.90 | −$11.40 |
| STS | 5 | $0.18 | $21.90 | −$21.72 |
Each AWS service needs its own interface endpoint, so the fixed cost repeats for every service you add. Low-volume control-plane calls such as STS almost never justify an endpoint on cost alone. If you deploy one for those calls, the reason should be security or availability. The NAT gateway's own hourly charge also stays the same unless you can remove the gateway entirely.
Treat this table as a method, not an answer. Substitute your region's current rates and the per-destination volumes you measured earlier. Then rerun the calculation whenever pricing changes or traffic patterns shift. A break-even figure calculated last year can be wrong today.
When to avoid endpoints and what can go wrong
Interface endpoints carry a fixed hourly charge for every AZ they are deployed in, and that charge is the same whether they carry a terabyte or nothing at all. In a small cluster, a dev environment that idles overnight, or an organization with dozens of low-traffic VPCs, those per-AZ hours can exceed the NAT processing charges they remove. Gateway endpoints for S3 and DynamoDB have no hourly charge, so they are rarely a bad deal. Interface endpoints are where the loss usually happens.
Centralizing endpoints
Hosting endpoints in a shared-services VPC reduces how many you need, but the savings are offset in three ways:
- Transit Gateway bills per attachment-hour and per GB processed. That per-GB charge partly replaces the NAT charge you were trying to eliminate.
- Peering has no hourly fee, but cross-AZ data transfer still applies.
- DNS gets harder. Private DNS on an endpoint only works inside its own VPC. You have to disable it, create private hosted zones for each service, and associate those zones with every spoke VPC. Cross-account associations require explicit authorization.
Failure modes
- Private DNS affects the whole VPC. When you enable it, the service's public hostname resolves to the endpoint for every workload in the VPC, not only the one you were targeting.
- Misleading errors. A security group that blocks 443, or an overly restrictive endpoint policy, typically produces timeouts or AccessDenied responses. These are easy to misread as IAM or credential problems.
- Node bootstrap and image pulls. ECR needs both its API and Docker endpoints, plus S3, because image layers are served from S3. If one of these is missing, nodes may fail to join the cluster or pods may stall pulling images. Neither failure points to the actual cause.
- Bucket policies using
aws:SourceVpce. A condition like this denies every request path you didn't list, including console access, CI runners, and other VPCs. Test it in audit or staging before enforcing it.
Alternatives: AZ-local NAT design and reducing bytes at the source
Endpoints only fix traffic to AWS services. The rest of your NAT bill depends on topology and on how many bytes you send.
Keep traffic in its availability zone
A pod in AZ-a that routes through a NAT gateway in AZ-b pays the cross-AZ transfer charge as well as NAT processing, in both directions. Running one NAT gateway per AZ, with one private route table per AZ pointing at the local gateway, removes that charge. The cost is an extra hourly fee for each additional gateway. Interface endpoints raise the same question. Place an ENI in every AZ that runs workloads, otherwise traffic from AZs without one crosses zones to reach the endpoint.
| Design | Hourly cost | Cross-AZ charges | Failure impact |
|---|---|---|---|
| Single shared NAT gateway | Lowest | Yes, from other AZs | Its AZ failing cuts egress for all AZs |
| One NAT gateway per AZ | Scales with AZ count | None | Contained to one AZ |
| Self-managed NAT instances | Instance cost only | Depends on placement | You own patching, failover, and capacity |
| No NAT (endpoints only) | None | None | Anything not covered by an endpoint fails |
NAT instances look cheap, but their throughput is capped by the instance type's network bandwidth. You also have to build health checks and route failover yourself.
Send fewer bytes
These changes cut traffic whichever path it takes:
- Mirror external images. Use an ECR pull-through cache or copy images into ECR, so Docker Hub and other registry pulls stop going through NAT.
- Shrink images. Use smaller images and share base layers, so nodes download each layer once.
- Pre-pull or bake images. For node groups that scale often, pre-pull images or bake them into the AMI, so new nodes don't each pull the same images.
- Filter telemetry at the agent. Drop debug logs and high-cardinality metrics in the agent, before anything leaves the node.
- Compress chatty egress. Enable compression for verbose outbound APIs and batched exports.
Impact on cost and engineering time: verification and guardrails
Verify that traffic actually moved
Compare NAT data processed over two equal windows, one before the rollout and one after. Each window should cover a full weekly cycle, because batch jobs and deploy days skew shorter ones. A simple check is to run aws cloudwatch get-metric-statistics --namespace AWS/NATGateway --metric-name BytesOutToDestination --dimensions Name=NatGatewayId,Value=<nat-id> --statistics Sum --period 86400 twice, changing only --start-time and --end-time. Repeat for BytesInFromDestination, since NAT bills bytes in both directions.
A drop in NAT bytes is not enough proof on its own. Confirm where the traffic went:
- Interface endpoints: check the endpoint's own CloudWatch traffic metrics.
- Gateway endpoints: these publish no per-endpoint byte metrics. Use VPC flow logs instead, and look for the S3 or DynamoDB address ranges disappearing from flows through the NAT's network interface.
Lock it in
After traffic has run cleanly through an endpoint, add an endpoint policy that limits it to your accounts or buckets. Add the policy after testing, because a restrictive policy applied too early produces access-denied errors that look like application bugs.
Schedule a monthly or quarterly review that reruns the attribution from section 2. New workloads, new regions, and new SaaS dependencies are how NAT-heavy paths come back.
Cost of the work versus the savings
The engineering cost is mostly one-time: a few days to measure, deploy targeted endpoints, and verify. Ongoing costs are endpoint hourly charges and the periodic review.
Measuring before targeting also saves engineering time. Deploying endpoints for every service up front adds hourly charges for endpoints that carry little traffic. It also adds DNS complexity and policies to maintain, and someone later has to audit and remove what isn't needed. Deploying only where the data shows heavy traffic avoids that cleanup project.
#!/usr/bin/env bash
# Compare NAT gateway bytes across two equal one-week windows (before vs after endpoint rollout).
set -euo pipefail
NAT_ID="nat-0123456789abcdef0" # placeholder NAT gateway ID
REGION="us-east-1"
# Use full weeks that start on the same weekday so batch jobs and deploy days line up.
BEFORE_START="2026-10-05T00:00:00Z"; BEFORE_END="2026-10-12T00:00:00Z"
AFTER_START="2026-10-19T00:00:00Z"; AFTER_END="2026-10-26T00:00:00Z"
# Sum the daily totals of one NAT metric over a window.
nat_bytes() {
aws cloudwatch get-metric-statistics \
--region "$REGION" \
--namespace AWS/NATGateway \
--metric-name "$1" \
--dimensions Name=NatGatewayId,Value="$NAT_ID" \
--start-time "$2" --end-time "$3" \
--period 86400 --statistics Sum \
--query 'sum(Datapoints[].Sum)' --output text
}
# Check both directions; NAT data processing is charged on traffic either way.
for metric in BytesOutToDestination BytesInFromDestination; do
before=$(nat_bytes "$metric" "$BEFORE_START" "$BEFORE_END")
after=$(nat_bytes "$metric" "$AFTER_START" "$AFTER_END")
awk -v m="$metric" -v b="$before" -v a="$after" 'BEGIN {
change = (b > 0) ? (a - b) / b * 100 : 0
printf "%-24s before=%8.1f GB after=%8.1f GB change=%6.1f%%\n", m, b/1e9, a/1e9, change
}'
done
A shell script that sums daily NAT gateway byte metrics from CloudWatch for a before week and an after week, in both directions, and prints the percentage change.
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 nat gateway cost reduction, 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.