In almost every AWS account I've reviewed there's a line on the bill that surprises everyone: the NAT Gateway. It's not a service anyone consciously chose. It shows up because the VPC creation wizard puts it there by default, because your Fargate tasks live in private subnets and need to reach the internet, and because nobody looked afterward. It charges per hour and per gigabyte processed, in both directions. And the worst part: much of that traffic isn't even going to the internet —it's going to S3, to DynamoDB, to Secrets Manager— and you could have pulled it out of there with a component that costs zero.
Why the NAT Gateway exists
A private subnet, by definition, has no route to an Internet Gateway. That's what makes it private: nothing outside can initiate a connection inward. But your containers do need to get out: to pull an image from ECR, to read a secret, to call a third-party API.
The NAT Gateway solves that: it lives in a public subnet, private subnets route their outbound traffic to it, and it translates addresses so responses come back. It works, it's managed, it's highly available within its AZ. And that's why everyone puts it there and forgets about it.
The problem is its pricing model. You pay an hourly rate per NAT Gateway —and since each one lives in an AZ, the recommended high-availability practice asks for one per AZ, so multiply— plus a rate for every gigabyte that crosses it. That per-gigabyte charge applies to processed traffic, and it accumulates without anyone watching.
The uncomfortable discovery: much of that traffic is internal
Here's the detail that changes the analysis. When your Fargate task in a private subnet writes an object to S3, that traffic exits through the NAT Gateway, goes to S3's public endpoint, and comes back. You're paying transfer to talk to an AWS service that sits in the same region as you.
Same with DynamoDB, with SQS, with Secrets Manager, with CloudWatch Logs —and CloudWatch Logs is the big silent suspect, because if you have verbose logs, every line is billed traffic— and with ECR every time a task starts and pulls image layers.
In well-populated serverless or container architectures, most outbound traffic isn't really going to the internet. It's going to AWS.
VPC endpoints: two flavors, very different prices
A VPC endpoint lets your private subnet talk to an AWS service without going through the internet or the NAT. There are two types, and confusing them is expensive.
Gateway endpoints exist only for S3 and DynamoDB. They're an entry in the route table. They cost nothing: not per hour, not per gigabyte. There is no defensible reason not to have them if you use S3 or DynamoDB from private subnets. It's money thrown away.
Interface endpoints (PrivateLink) are for everything else: SQS, Secrets Manager, ECR, CloudWatch, KMS. They create a network interface in your subnet with a private IP. These do cost: an hourly rate per endpoint per AZ, plus a per-gigabyte charge —quite a bit cheaper than the NAT's, but not zero.
💡 S3 and DynamoDB gateway endpoints are free. If your private subnets don't have them, you're paying NAT Gateway to talk to S3. That's the first place to look, and usually the most profitable.
How I decide which endpoints to create
With interface endpoints the math isn't automatic, because they have a fixed cost per hour per AZ. An endpoint pays off when the savings in NAT traffic exceed that fixed rate. The way to know is to look at the data, not guess: I enable Flow Logs on the NAT Gateway's elastic network interface and group by destination IP, resolving those IPs against AWS's published ranges to know which service is which.
With that table in front of you the decision is arithmetic. If 60% of your NAT traffic is to S3, a free gateway endpoint takes all of it. If another 20% is CloudWatch Logs, the interface endpoint pays for itself. If there's 5% to KMS, the fixed cost probably isn't worth it.
In OpenTofu it's so little code it's almost embarrassing not to have it:
# Free. No excuses.
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.private[*].id
}
# Paid: creates one ENI per subnet. Justify it with data.
resource "aws_vpc_endpoint" "logs" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.region}.logs"
vpc_endpoint_type = "Interface"
subnet_ids = aws_subnet.private[*].id
security_group_ids = [aws_security_group.endpoints.id]
private_dns_enabled = true # without this, your SDK keeps going to the public endpoint
}
That private_dns_enabled is what makes it work without touching code: it resolves the service's public name to the endpoint's private IP. If you leave it at false, you create the endpoint, pay for it, and your application keeps happily exiting through the NAT. It's an error you only see on the bill.
The most uncomfortable question: do you need the NAT?
Before optimizing the NAT Gateway it's worth asking whether it's needed at all. If your containers only talk to AWS services and never call a third-party API, you can cover everything with endpoints and remove it. It's the only optimization that takes that cost to zero.
In practice there's almost always something going out to the internet: a webhook, a payment gateway, an LLM provider. So the NAT stays, but with most traffic diverted, and its bill goes from being a mystery to being a small, explainable line.
The honest trade-off: you add networking components that have to be maintained, versioned in your IaC, and debugged when a misconfigured security group makes an endpoint throw timeouts instead of clear errors. In exchange, internal traffic stops touching the internet —which is also better security posture— and the bill stops growing with your log volume. For me it's always been worth it. But decide it with Flow Logs, not with this article.