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.api and ecr.dkr for image pulls
  • sts for IRSA
  • logs for log shipping
  • ec2 for node and CNI calls
  • elasticloadbalancing for 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.