
VPC Gateway Endpoints: The Free AWS Cost Saver Most Teams Never Turn On
Your NAT Gateway is billing you by the gigabyte for traffic that AWS will happily route for free — you just have to flip the switch.
Here's the finding: if any of your private subnets talk to S3 or DynamoDB, and you haven't set up a VPC Gateway Endpoint, that traffic is almost certainly leaving your subnet, hitting your NAT Gateway, and getting billed per gigabyte — for a trip that never needed to leave AWS's own network in the first place. This is one of the few AWS cost fixes that's a pure win: no latency tradeoff, no availability tradeoff, and it takes about five minutes to set up.
Why this happens
A NAT Gateway exists so that resources in a private subnet (no public IP) can reach the internet — to pull a package, call an external API, whatever. S3 and DynamoDB are AWS services, not "the internet" in any meaningful sense, but by default your route table doesn't know that. Traffic to s3.amazonaws.com or a DynamoDB endpoint follows the same default route (0.0.0.0/0) as traffic to any other address, which on a private subnet usually points at the NAT Gateway.
NAT Gateway pricing has two parts: an hourly charge for the gateway just existing, and a per-GB data processing charge for every gigabyte that flows through it, in both directions. At the commonly-cited US rate of roughly $0.045 per GB processed (on top of the $0.045/hr the gateway costs just to exist), a pipeline moving 10 TB a month to and from S3 through a NAT Gateway is paying on the order of $460/month in data processing charges alone — for traffic that a Gateway Endpoint moves for $0.00.
That number scales with whatever your actual S3/DynamoDB traffic looks like: backup jobs, data pipelines reading/writing to S3, Lambda functions in a VPC hitting DynamoDB, ECS tasks pulling config from S3 on every deploy. None of it needs to touch the internet, and none of it needs to cost anything once the Gateway Endpoint exists.
What a Gateway Endpoint actually is
A VPC Gateway Endpoint is a route table entry, not a network appliance. AWS supports Gateway Endpoints for exactly two services — S3 and DynamoDB — and for those two, the endpoint costs nothing to create or run, and nothing per gigabyte. (Everything else — Secrets Manager, SQS, ECR, CloudWatch Logs, and so on — uses a different mechanism called an Interface Endpoint, which creates an ENI with a private IP and does carry an hourly charge plus a smaller per-GB fee. Don't confuse the two: Gateway Endpoints are the free ones, and they only cover S3 and DynamoDB.)
Once the endpoint exists and your route table points S3/DynamoDB traffic at it instead of the NAT Gateway, requests to those services resolve to AWS's internal network and never leave it. No NAT, no data processing charge, and typically lower latency too, since the packet isn't making a round trip through a NAT appliance.
Checking whether this affects you
Start by checking whether you already have a Gateway Endpoint in the VPCs that matter:
aws ec2 describe-vpc-endpoints \
--filters "Name=vpc-endpoint-type,Values=Gateway" \
--query "VpcEndpoints[].{VPC:VpcId,Service:ServiceName,State:State,RouteTables:RouteTableIds}" \
--output tableIf that comes back empty for a VPC that has private subnets talking to S3 or DynamoDB, you're paying NAT Gateway data processing charges you don't have to. You can get a rough sense of how much NAT traffic is S3-bound by checking the NAT Gateway's CloudWatch metrics — BytesOutToDestination and BytesInFromDestination on the AWS/NATGateway namespace give you total throughput, which combined with VPC Flow Logs filtered to S3's published IP ranges will tell you how much of that NAT bill is attributable to S3/DynamoDB traffic specifically:
aws cloudwatch get-metric-statistics \
--namespace AWS/NATGateway \
--metric-name BytesOutToDestination \
--dimensions Name=NatGatewayId,Value=nat-0123456789abcdef0 \
--start-time 2026-10-01T00:00:00Z \
--end-time 2026-10-30T00:00:00Z \
--period 2592000 \
--statistics SumWorked example: adding the endpoint with Terraform
This is the whole fix. For S3, in the VPC containing your private subnets:
data "aws_route_table" "private" {
for_each = toset(var.private_subnet_ids)
subnet_id = each.value
}
resource "aws_vpc_endpoint" "s3" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [for rt in data.aws_route_table.private : rt.id]
# Lock the endpoint down to your own buckets rather than leaving it open to all of S3
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = "*"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3:::${var.bucket_name}",
"arn:aws:s3:::${var.bucket_name}/*"
]
}]
})
tags = {
Name = "s3-gateway-endpoint"
}
}
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = [for rt in data.aws_route_table.private : rt.id]
tags = {
Name = "dynamodb-gateway-endpoint"
}
}The equivalent, if you're doing it directly rather than through Terraform:
aws ec2 create-vpc-endpoint \
--vpc-id vpc-0123456789abcdef0 \
--service-name com.amazonaws.us-east-1.s3 \
--vpc-endpoint-type Gateway \
--route-table-ids rtb-0123456789abcdef0 rtb-0fedcba9876543210terraform apply adds the route table entries automatically — there's no instance to reboot and no downtime. Traffic to S3/DynamoDB starts using the endpoint the moment the route exists; traffic to everything else keeps going through the NAT Gateway exactly as before.
The gotchas
- Gateway Endpoints are regional. If your private subnet needs to reach an S3 bucket in a different region, that traffic still needs to go out some other path — the Gateway Endpoint only covers same-region traffic.
- Lock down the endpoint policy. By default a Gateway Endpoint can reach any S3 bucket or DynamoDB table in the region, including ones outside your account. Scope the policy to the specific resources that subnet actually needs, the same way you would with an IAM policy.
- It's a route table change, not a security group change. If traffic still isn't using the endpoint after creating it, check that the relevant private subnet's route table is actually in the endpoint's
route_table_idslist — a subnet on a route table you didn't include will keep going through NAT with zero errors or warnings. - Everything that isn't S3/DynamoDB still needs either NAT or an Interface Endpoint. This fix doesn't eliminate your NAT Gateway, it just removes the S3/DynamoDB share of its bill — which for a lot of data-heavy workloads turns out to be the majority of it.
Check your NAT Gateway bill
Pull up Cost Explorer, filter to NAT Gateway data processing charges, and cross-reference against how much of your traffic is actually S3/DynamoDB-bound. If it's nonzero and you don't have a Gateway Endpoint yet, this is as close to a free cost fix as AWS offers — ten minutes of Terraform against a bill that otherwise compounds every month, indefinitely, for traffic that never needed to leave AWS's network.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


