Find idle NAT gateways before they bill $33 a month

Your AWS bill has a networking line item that charges the same whether traffic is pouring through it or your VPC has been asleep for a month. It is the NAT gateway. Each one costs about $33 a month just to exist, before a single byte moves. The ones quietly draining money are the idle gateways: the dev VPC nobody tore down, the second gateway you spun up for high availability in an Availability Zone that no longer runs anything. Here is how to find them by hand and shut them off without breaking a route.

Why an idle NAT gateway still costs $33 a month

A NAT gateway has two charges, and only one of them depends on traffic.

The first is the hourly charge. In us-east-1 a NAT gateway lists at $0.045 per hour. AWS bills every partial hour as a full one, so a gateway that exists for a month costs about $32.85 (730 hours times $0.045) no matter what it does. That charge runs while the gateway sits idle, exactly as it does while the gateway is busy.

The second is data processing, at $0.045 per gigabyte that passes through the gateway. Any traffic bound for the open internet also carries the standard $0.09 per GB egress rate, so a byte leaving your VPC through a NAT gateway costs about $0.135 all in. Data processing only bills when traffic actually flows, so an idle gateway dodges it. The hourly charge is the one that bleeds on a gateway nobody uses.

Now multiply. A common high-availability pattern puts one NAT gateway in each Availability Zone a workload spans. Three AZs mean three gateways and roughly $99 a month in hourly charges before any data moves. When the workload behind two of those zones gets torn down but the gateways stay, you are paying for standby capacity that routes nothing.

The trap: a busy gateway can waste as much as an idle one

A busy NAT gateway can waste as much as an idle one when it routes S3 and DynamoDB traffic that a free gateway VPC endpoint would carry. Hunting idle gateways catches the obvious waste. The subtler drain sits on the gateways that look busy.

A lot of NAT gateway traffic is not headed for the open internet at all. It is your instances talking to Amazon S3 and DynamoDB, two services that live inside AWS. Route that traffic through a NAT gateway and you pay the $0.045 per GB processing charge, plus the $0.09 per GB egress, for data that never left AWS.

S3 and DynamoDB each support a gateway VPC endpoint, and the gateway endpoint is free. It adds an entry to your route table that sends S3 and DynamoDB traffic straight to the service over the AWS network, skipping the NAT gateway entirely. It costs nothing to run and nothing per gigabyte.

Put a number on it. An application pulling 1 TB of objects from S3 each month through a NAT gateway runs about $135 in processing and egress. The same traffic over a gateway endpoint costs $0. Only S3 and DynamoDB offer gateway endpoints, so this is the first thing to check on any gateway carrying real volume before you assume that traffic has to be there.

To measure how much of a busy gateway's traffic is S3 or DynamoDB, turn on VPC Flow Logs for the subnet and group the destinations against the S3 and DynamoDB prefix lists from aws ec2 describe-managed-prefix-lists. Traffic that lands in those prefixes is what a free gateway endpoint takes off the gateway.

Find your idle gateways by hand

To spot an idle NAT gateway, you sum its CloudWatch BytesOutToDestination over 30 days and confirm it sent almost nothing. Start with the inventory. List every available NAT gateway in the region, with its VPC, subnet, and public IP.

aws ec2 describe-nat-gateways \
  --filter Name=state,Values=available \
  --query "NatGateways[].{id:NatGatewayId,vpc:VpcId,subnet:SubnetId,ip:NatGatewayAddresses[0].PublicIp}" \
  --output table

That is the list of gateways you pay the hourly charge on. NAT gateways are regional, so run this in every region you have ever used, since a forgotten gateway in a region you rarely open still bills every hour. Pass --region explicitly, or loop over the output of aws ec2 describe-regions --query "Regions[].RegionName" --output text.

Now ask each one whether it is actually moving traffic. The metric that answers this is BytesOutToDestination in the AWS/NATGateway CloudWatch namespace. AWS defines a value greater than zero as traffic going to the internet from clients behind the gateway. Sum it over a full 30 days.

aws cloudwatch get-metric-statistics \
  --namespace AWS/NATGateway \
  --metric-name BytesOutToDestination \
  --dimensions Name=NatGatewayId,Value=nat-0123456789abcdef0 \
  --start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 86400 \
  --statistics Sum \
  --query "Datapoints[].Sum" \
  --output json | jq 'add // 0'

That prints the total bytes the gateway sent to the internet over the window. A NAT gateway that sends under 1 GB to the internet across 30 days is idle. Divide the byte total by 1073741824 to read it in gigabytes. The date -d form is GNU coreutils. On macOS, use date -u -v-30d +%Y-%m-%dT%H:%M:%SZ for the start time instead.

Confirm it with a second metric before you act. ActiveConnectionCount reports concurrent TCP connections through the gateway, and AWS defines a value of zero as no active connections. Read its maximum over the same window.

aws cloudwatch get-metric-statistics \
  --namespace AWS/NATGateway \
  --metric-name ActiveConnectionCount \
  --dimensions Name=NatGatewayId,Value=nat-0123456789abcdef0 \
  --start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 86400 \
  --statistics Maximum \
  --query "Datapoints[].Maximum" \
  --output json | jq 'max // 0'

Here is the part that trips people. Measure a full billing cycle, not a quiet afternoon. A gateway can read as dead all week and then light up on the first of the month when a backup job or a batch export runs. Thirty days of near-zero BytesOutToDestination and a peak ActiveConnectionCount of zero is a gateway you can retire. A single low day is not enough to call it.

Delete an idle gateway without breaking routes

Deleting a NAT gateway is one call. The care goes into the route it serves and the Elastic IP attached to it.

First, find the route tables that send traffic to the gateway. Any subnet whose default route points at a gateway you are about to delete loses its path to the internet the moment it goes.

aws ec2 describe-route-tables \
  --filters Name=route.nat-gateway-id,Values=nat-0123456789abcdef0 \
  --query "RouteTables[].{rt:RouteTableId,subnets:Associations[].SubnetId}" \
  --output json

If that returns nothing, no subnet routes through the gateway, and it is safe to remove. If it returns a route table, decide what those subnets should do instead: route through a different gateway, or nothing at all when the workload is gone.

Note the Elastic IP before you delete, because you will want it for rollback. Grab the allocation ID.

aws ec2 describe-nat-gateways \
  --nat-gateway-ids nat-0123456789abcdef0 \
  --query "NatGateways[0].NatGatewayAddresses[0].AllocationId" \
  --output text

Now delete the gateway.

aws ec2 delete-nat-gateway --nat-gateway-id nat-0123456789abcdef0

The gateway moves to deleting, and the hourly charge stops once it reaches deleted. Deleting the gateway does not remove the route that pointed at it. That entry stays in the route table and flips to a blackhole state, so the subnet quietly drops internet-bound packets until you fix it. Repoint it with aws ec2 replace-route, or delete it with aws ec2 delete-route.

The Elastic IP is not removed with the gateway. It returns to your account as an unassociated address, which matters for two reasons. A NAT gateway takes a fresh public IP every time you create one, so keeping the allocation lets you rebuild at the same address. And an unassociated Elastic IP bills at about $0.005 an hour (roughly $3.60 a month), so release it once you are sure you will not rebuild.

Rollback is a rebuild. If you cut the wrong gateway, create a new one in the same subnet with the allocation ID you saved, then repoint the route table at it.

aws ec2 create-nat-gateway \
  --subnet-id subnet-0123456789abcdef0 \
  --allocation-id eipalloc-0123456789abcdef0

aws ec2 create-route \
  --route-table-id rtb-0123456789abcdef0 \
  --destination-cidr-block 0.0.0.0/0 \
  --nat-gateway-id nat-0987654321fedcba0

Because you kept the Elastic IP, the rebuilt gateway comes back on the same public address, so anything that allowlisted that IP keeps working.

A quick decision tree

graph TD
  A[NAT gateway on the bill] --> B{Sum of BytesOutToDestination over 30 days}
  B -- Under ~1 GB --> C{Peak ActiveConnectionCount = 0?}
  B -- Well above 1 GB --> D{Traffic mostly S3 or DynamoDB?}
  C -- Yes --> E[Idle: delete it, keep the Elastic IP for rollback]
  C -- No --> F[A monthly job may use it. Recheck over a full cycle]
  D -- Yes --> G[Add a free gateway endpoint and cut the processing charge]
  D -- No --> H[Real internet egress: keep the gateway]

The same shape of waste, paying for capacity that sits idle, shows up all over a cloud bill.

How OhChimp approaches this

Doing this by hand works. The catch is that idle gateways hide in VPCs and regions nobody opens, and a NAT gateway is easy to create and easy to forget.

OhChimp scans your AWS accounts, sums BytesOutToDestination over the window, and flags the gateways under the idle rate, alongside unattached Elastic IPs and load balancers with no healthy targets. Each one becomes a reviewable plan with a confidence score, a risk level, and rollback steps, and because the Elastic IP survives a delete, a flagged gateway can be rebuilt at the same address. You click apply, nothing moves until you do, and the saving is measured against your real AWS bill.

See how the AWS connection works on the AWS integration page.