JSTGTECH
← Back to blog

Cutting NAT gateway costs with VPC endpoints that actually help

6 min read

A NAT gateway bills $0.045/hour just to exist (about $32/month per AZ) plus $0.045/GB processed, in every region, for every private subnet that needs outbound internet access. Most of that traffic, on a typical workload, isn’t actually going to the internet — it’s a Lambda function calling S3, an ECS task writing to DynamoDB, or an EC2 instance pulling a Secrets Manager value, all routed out through NAT and back into AWS because nothing told the VPC a shorter path existed. VPC endpoints are that shorter path. They don’t replace NAT gateways outright, but on most accounts they eliminate the majority of the traffic NAT was ever pushing.

Two different mechanisms, and it matters which one you reach for

“VPC endpoint” covers two unrelated implementations that happen to share a name:

  • Gateway endpoints — S3 and DynamoDB only. A gateway endpoint is a route table entry, not a network interface. Traffic to the service’s address range is routed directly within AWS’s network instead of out through the internet gateway or NAT. There’s no hourly charge and no per-GB charge — it’s free.
  • Interface endpoints (AWS PrivateLink) — everything else: DynamoDB (as an alternative), Secrets Manager, SSM, SQS, SNS, ECR, CloudWatch Logs, Bedrock, and most other AWS services. An interface endpoint provisions an elastic network interface with a private IP in your subnet, and AWS gives it a DNS name that resolves in place of the public service endpoint (when you enable private DNS). These cost $0.01/hour per AZ you deploy into, plus $0.01/GB processed — not free, but usually far cheaper than the NAT traffic it replaces once you look at the actual GB numbers.

The pricing gap is why the order of operations matters: turn on gateway endpoints for S3 and DynamoDB first, always — there’s no cost trade-off to evaluate, they’re strictly free traffic that used to route through paid NAT. Interface endpoints are the ones that need a per-service cost comparison before you add them.

Adding a gateway endpoint

Gateway endpoints attach to specific route tables, not the whole VPC, so private subnets need the association explicitly:

aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0123456789abcdef0 \
  --service-name com.amazonaws.us-east-1.s3 \
  --route-table-ids rtb-0a1b2c3d4e5f6a7b8 rtb-1a2b3c4d5e6f7a8b9 \
  --vpc-endpoint-type Gateway

In Terraform:

resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.us-east-1.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [for rt in aws_route_table.private : rt.id]
}

Check that it actually took effect with a route table describe — you’re looking for a pl- (prefix list) destination pointing at the endpoint’s ID, not a 0.0.0.0/0 route to NAT for S3 traffic:

aws ec2 describe-route-tables --route-table-ids rtb-0a1b2c3d4e5f6a7b8 \
  --query 'RouteTables[0].Routes[?DestinationPrefixListId!=`null`]'

Adding an interface endpoint

Interface endpoints need a subnet placement (one ENI per AZ you list) and a security group, since they’re a real network interface that other resources connect to:

resource "aws_vpc_endpoint" "secretsmanager" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.us-east-1.secretsmanager"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.vpc_endpoints.id]
  private_dns_enabled = true
}

resource "aws_security_group" "vpc_endpoints" {
  vpc_id = aws_vpc.main.id
  ingress {
    from_port       = 443
    to_port         = 443
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }
}

private_dns_enabled = true is what makes this transparent to application code: the SDK still calls secretsmanager.us-east-1.amazonaws.com, but Route 53 Resolver answers with the endpoint’s private IP instead of the public one, inside the VPC. Nothing in your application config changes — which is also why it’s easy to add one and not notice it’s doing nothing, covered below.

Where NAT still has to stay

VPC endpoints only cover AWS service APIs that have a PrivateLink or gateway implementation. NAT (or an internet gateway with a public IP, for public subnets) is still required for:

  • Calls to non-AWS third-party APIs — Stripe, Datadog, npm/PyPI registries during a build, any SaaS webhook target. There’s no PrivateLink endpoint for the general internet.
  • AWS services without a PrivateLink endpoint in your region yet — check the AWS PrivateLink service list per-region before assuming coverage; newer or smaller services lag.
  • Cross-region calls to AWS services — an interface endpoint is regional; a us-east-1 subnet calling an S3 bucket in eu-west-1 through a gateway endpoint still needs a path out, because the endpoint only covers same-region traffic patterns for most services (S3 gateway endpoints are a partial exception via cross-region access points, but don’t assume it works until you’ve checked the specific service).

Don’t decommission your NAT gateway because you added endpoints for your top three services — audit what’s actually calling out first (see below), because the leftover traffic is usually smaller but never zero.

Finding out what’s actually costing you, before you guess

Don’t add endpoints speculatively for every service AWS offers — at $0.01/hour/AZ each, a dozen unused interface endpoints across 3 AZs is $26/month for nothing. VPC Flow Logs tell you what’s actually flowing through NAT right now. Enable them on the NAT gateway’s ENI (or the whole VPC) and query the destination:

fields dstAddr, bytes
| filter srcAddr like /^10\./
| stats sum(bytes) as totalBytes by dstAddr
| sort totalBytes desc
| limit 20

Cross-reference the top destination IPs against AWS’s published IP address ranges for your region — jq '.prefixes[] | select(.region=="us-east-1") | .service' ip-ranges.json gives you the service name per CIDR block. If a destination IP resolves to DYNAMODB or S3, that traffic is a candidate for a free gateway endpoint right now. If it resolves to SECRETSMANAGER or ECR and represents a meaningful share of your NAT bytes total, run the $0.01/GB endpoint cost against what that GB volume currently costs at NAT’s $0.045/GB — the endpoint usually wins by a wide margin, but a low-traffic service isn’t worth the flat hourly charge across every AZ, since that’s a fixed cost you pay whether or not it’s used, unlike NAT’s per-GB-only marginal cost for that same traffic.

The gotcha: adding the endpoint doesn’t guarantee it gets used

An interface endpoint with private DNS enabled changes DNS resolution, but only for resolvers that actually query the VPC’s Route 53 Resolver. Three ways this silently fails to redirect traffic, leaving you paying for both the endpoint and unchanged NAT usage:

  • A custom DNS server configured in the VPC’s DHCP options set that doesn’t forward to the AWS-provided .2 resolver — the private DNS record never gets seen, so the SDK still resolves the public IP and routes out through NAT anyway.
  • A hardcoded regional or FIPS endpoint URL in application config (https://s3.dualstack.us-east-1.amazonaws.com or a client explicitly configured with a non-default endpoint) bypasses the standard hostname the private DNS record matches.
  • Cached negative DNS lookups from before the endpoint existed — some container base images or runtimes cache resolver failures more aggressively than successes; a task that started before the endpoint went live may need a restart, not just time, to pick up the new record.

Confirm it’s actually working, don’t assume it: dig the service hostname from inside a resource in the private subnet and check the answer is a private (10.x/172.16.x/192.168.x) address, then watch the endpoint’s own CloudWatch metrics (BytesProcessed on the interface endpoint) climb while the NAT gateway’s BytesOutToDestination correspondingly flattens. If the endpoint’s traffic metric stays near zero after deployment, something in the DNS resolution path above is still routing around it.

What to actually do

Turn on S3 and DynamoDB gateway endpoints everywhere, immediately — they’re free and there’s no scenario where they make things worse. For everything else, pull a week of VPC Flow Logs against the NAT gateway, resolve the top destination IPs against AWS’s IP ranges, and add interface endpoints only for the services showing real GB volume, checking each one actually took over the traffic afterward rather than assuming the Terraform apply was enough. On a typical account this drops NAT gateway processing charges sharply without touching NAT’s role for genuine third-party internet traffic, which is the traffic it was actually built for.

Related posts