AWS VPC Endpoints - Keep Your Traffic Off the Internet
Your Lambda function calls S3. Your EC2 instance talks to Secrets Manager. Your ECS tasks pull from ECR. By default, all this traffic routes through the internet - even though both ends are in AWS.
VPC Endpoints change this. They let resources in your VPC access AWS services without traversing the public internet. Traffic stays on AWS’s private network, improving security and often reducing costs.
This post covers when to use VPC endpoints, the difference between Gateway and Interface endpoints, endpoint policies, cost considerations, and production Terraform patterns.
TL;DR
- Gateway endpoints (S3, DynamoDB) are free - always use them
- Interface endpoints (everything else) cost ~$7.50/month per AZ + data processing
- Private DNS lets you use normal service URLs without code changes
- Endpoint policies add another layer of access control
- In private subnets without NAT, endpoints are required for AWS service access
- Multi-AZ deployment recommended for production
Code Repository: All code from this post is available at github.com/moabukar/blog-code/vpc-endpoints
Why VPC Endpoints?
Without VPC endpoints, traffic from private subnets to AWS services must go through NAT:
┌─────────────────────────────────────────────────────────────────┐
│ Without VPC Endpoints │
└─────────────────────────────────────────────────────────────────┘
Private Subnet NAT Gateway Internet Gateway
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ EC2 Instance│ ──────► │ NAT │ ────► │ IGW │
│ │ │ Gateway │ │ │
│ s3:GetObject│ │ ($0.045/GB) │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ S3 Public │
│ Endpoint │
└─────────────┘
Problems:
- Cost: NAT Gateway charges $0.045/GB for data processing
- Security: Traffic touches the public internet (even if encrypted)
- Latency: Extra hops through NAT and IGW
- Dependency: NAT Gateway failure = no service access
With VPC endpoints:
┌─────────────────────────────────────────────────────────────────┐
│ With VPC Endpoints │
└─────────────────────────────────────────────────────────────────┘
Private Subnet AWS Network
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ EC2 Instance│ ───────────────────────►│ S3 │
│ │ VPC Endpoint │ Service │
│ s3:GetObject│ (FREE) │ │
└─────────────┘ └─────────────┘
Traffic stays private, no NAT costs for S3/DynamoDB, and reduced attack surface.
Gateway vs Interface Endpoints
AWS offers two types of VPC endpoints:
Gateway Endpoints (S3 and DynamoDB only)
- FREE - no hourly charge, no data processing charge
- Works via route table entries
- Traffic uses AWS backbone network
- No ENI created in your subnet
GATEWAY ENDPOINTS
=================
Service Cost How It Works
------- ---- ------------
Amazon S3 Free Route table prefix list
Amazon DynamoDB Free Route table prefix list
Interface Endpoints (Everything else)
- $0.01/hour per AZ (~$7.50/month)
- $0.01/GB data processed
- Creates an ENI in your subnet with private IP
- Uses AWS PrivateLink
- Supports private DNS
INTERFACE ENDPOINTS (PrivateLink)
=================================
Service Typical Use Case
------- ----------------
Secrets Manager Retrieve secrets from Lambda/ECS
SSM (Parameter Store) Configuration management
ECR Pull container images
KMS Encrypt/decrypt operations
STS Assume roles
CloudWatch Logs Ship logs from private subnets
SNS/SQS Messaging from private workloads
Lambda Invoke functions privately
API Gateway (Private) Internal APIs
Gateway Endpoint for S3
Since S3 gateway endpoints are free, always create them:
# Get the VPC's route tables
data "aws_route_tables" "private" {
vpc_id = aws_vpc.main.id
filter {
name = "tag:Tier"
values = ["private"]
}
}
# S3 Gateway Endpoint
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = data.aws_route_tables.private.ids
tags = {
Name = "s3-gateway-endpoint"
}
}
After creation, routes are automatically added to your route tables:
Destination Target
----------- ------
pl-63a5400a (S3) vpce-0abc123def456
The prefix list (pl-63a5400a) contains all S3 IP ranges for the region.
DynamoDB Gateway Endpoint
Same pattern:
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = data.aws_route_tables.private.ids
tags = {
Name = "dynamodb-gateway-endpoint"
}
}
Interface Endpoints
Interface endpoints create ENIs in your subnets. Here’s the pattern for common services:
Secrets Manager
resource "aws_vpc_endpoint" "secretsmanager" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "secretsmanager-endpoint"
}
}
ECR (Container Registry)
ECR requires multiple endpoints:
# ECR API endpoint
resource "aws_vpc_endpoint" "ecr_api" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.ecr.api"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "ecr-api-endpoint"
}
}
# ECR Docker endpoint (for image pulls)
resource "aws_vpc_endpoint" "ecr_dkr" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.ecr.dkr"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "ecr-dkr-endpoint"
}
}
# S3 Gateway endpoint (ECR stores layers in S3)
# Already created above - ecr.dkr pulls layers from S3
CloudWatch Logs
resource "aws_vpc_endpoint" "logs" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.logs"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "cloudwatch-logs-endpoint"
}
}
SSM (Parameter Store + Session Manager)
SSM requires three endpoints:
resource "aws_vpc_endpoint" "ssm" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.ssm"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "ssm-endpoint"
}
}
resource "aws_vpc_endpoint" "ssmmessages" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.ssmmessages"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "ssmmessages-endpoint"
}
}
resource "aws_vpc_endpoint" "ec2messages" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.ec2messages"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "ec2messages-endpoint"
}
}
Security Groups for Endpoints
Interface endpoints need security groups. Create one that allows HTTPS from your VPC:
resource "aws_security_group" "vpc_endpoints" {
name = "vpc-endpoints"
description = "Security group for VPC endpoints"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "vpc-endpoints-sg"
}
}
For tighter security, restrict to specific subnets:
resource "aws_security_group" "vpc_endpoints_restricted" {
name = "vpc-endpoints-restricted"
description = "Restricted security group for VPC endpoints"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS from private subnets"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = var.private_subnet_cidrs
}
ingress {
description = "HTTPS from EKS pods"
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.eks_pods.id]
}
tags = {
Name = "vpc-endpoints-restricted-sg"
}
}
Endpoint Policies
Endpoint policies add another layer of access control. They restrict what actions can be performed through the endpoint.
Restrict S3 Access to Specific Buckets
resource "aws_vpc_endpoint" "s3_restricted" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = data.aws_route_tables.private.ids
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowSpecificBuckets"
Effect = "Allow"
Principal = "*"
Action = "s3:*"
Resource = [
"arn:aws:s3:::${var.app_bucket}",
"arn:aws:s3:::${var.app_bucket}/*",
"arn:aws:s3:::${var.logs_bucket}",
"arn:aws:s3:::${var.logs_bucket}/*"
]
},
{
Sid = "AllowECRBuckets"
Effect = "Allow"
Principal = "*"
Action = [
"s3:GetObject"
]
Resource = [
"arn:aws:s3:::prod-${var.aws_region}-starport-layer-bucket/*"
]
}
]
})
tags = {
Name = "s3-gateway-endpoint-restricted"
}
}
Restrict Secrets Manager to Specific Secrets
resource "aws_vpc_endpoint" "secretsmanager_restricted" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowAppSecrets"
Effect = "Allow"
Principal = "*"
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = [
"arn:aws:secretsmanager:${var.aws_region}:${var.account_id}:secret:app/*",
"arn:aws:secretsmanager:${var.aws_region}:${var.account_id}:secret:shared/*"
]
}
]
})
tags = {
Name = "secretsmanager-endpoint-restricted"
}
}
Private DNS - The Key Feature You Shouldn’t Disable
When you enable private DNS for an interface endpoint, AWS creates a private hosted zone that resolves the service’s public DNS name to the endpoint’s private IPs.
Without private DNS:
secretsmanager.eu-west-1.amazonaws.com → 52.94.x.x (public IP)
With private DNS:
secretsmanager.eu-west-1.amazonaws.com → 10.0.1.x (endpoint ENI IP)
This means your code doesn’t need to change. The AWS SDK just works.
Requirements for private DNS:
- VPC must have
enableDnsHostnames = true - VPC must have
enableDnsSupport = true
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "main-vpc"
}
}
Cost Optimization
The $7.50/month/AZ Calculation
Interface endpoints cost:
- $0.01/hour per AZ = ~$7.30/month per AZ
- $0.01/GB data processed
For 3 AZs with 10 interface endpoints:
- Hourly: 10 × 3 × $0.01 × 730 hours = $219/month
- Plus data processing
Strategies to Reduce Costs
1. Consolidate to fewer AZs for non-critical workloads:
# Development - single AZ
resource "aws_vpc_endpoint" "secretsmanager_dev" {
count = var.environment == "dev" ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = [var.private_subnet_ids[0]] # Single AZ
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
# Production - multi-AZ
resource "aws_vpc_endpoint" "secretsmanager_prod" {
count = var.environment == "prod" ? 1 : 0
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids # All AZs
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
}
2. Use Gateway endpoints where possible (free):
S3 and DynamoDB gateway endpoints are always free. Always use them.
3. Evaluate if you actually need the endpoint:
If you have NAT Gateway anyway, and the traffic volume is low, the endpoint might cost more than NAT data processing.
Break-even calculation:
- Interface endpoint: $7.30/month + $0.01/GB
- NAT Gateway data: $0.045/GB
Break-even: ~162 GB/month. Below that, NAT is cheaper per endpoint.
But consider: security benefits, latency, NAT as single point of failure.
Production Terraform Module
Here’s a complete module for managing VPC endpoints:
# modules/vpc-endpoints/main.tf
variable "vpc_id" {
type = string
}
variable "private_subnet_ids" {
type = list(string)
}
variable "route_table_ids" {
type = list(string)
}
variable "aws_region" {
type = string
}
variable "vpc_cidr" {
type = string
}
variable "enable_s3_endpoint" {
type = bool
default = true
}
variable "enable_dynamodb_endpoint" {
type = bool
default = true
}
variable "interface_endpoints" {
type = list(string)
default = [
"secretsmanager",
"ssm",
"ssmmessages",
"ec2messages",
"logs",
"ecr.api",
"ecr.dkr",
"kms",
"sts"
]
}
# Security group for interface endpoints
resource "aws_security_group" "vpc_endpoints" {
name = "vpc-endpoints"
description = "Security group for VPC endpoints"
vpc_id = var.vpc_id
ingress {
description = "HTTPS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
tags = {
Name = "vpc-endpoints-sg"
}
}
# S3 Gateway Endpoint (FREE)
resource "aws_vpc_endpoint" "s3" {
count = var.enable_s3_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = var.route_table_ids
tags = {
Name = "s3-gateway-endpoint"
}
}
# DynamoDB Gateway Endpoint (FREE)
resource "aws_vpc_endpoint" "dynamodb" {
count = var.enable_dynamodb_endpoint ? 1 : 0
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = var.route_table_ids
tags = {
Name = "dynamodb-gateway-endpoint"
}
}
# Interface Endpoints
resource "aws_vpc_endpoint" "interface" {
for_each = toset(var.interface_endpoints)
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.${each.value}"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true
tags = {
Name = "${each.value}-endpoint"
}
}
output "s3_endpoint_id" {
value = var.enable_s3_endpoint ? aws_vpc_endpoint.s3[0].id : null
}
output "dynamodb_endpoint_id" {
value = var.enable_dynamodb_endpoint ? aws_vpc_endpoint.dynamodb[0].id : null
}
output "interface_endpoint_ids" {
value = { for k, v in aws_vpc_endpoint.interface : k => v.id }
}
output "security_group_id" {
value = aws_security_group.vpc_endpoints.id
}
Usage:
module "vpc_endpoints" {
source = "./modules/vpc-endpoints"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
route_table_ids = module.vpc.private_route_table_ids
aws_region = var.aws_region
vpc_cidr = var.vpc_cidr
enable_s3_endpoint = true
enable_dynamodb_endpoint = true
interface_endpoints = [
"secretsmanager",
"ssm",
"ssmmessages",
"ec2messages",
"logs",
"ecr.api",
"ecr.dkr"
]
}
Things Most People Don’t Know
1. ECR Needs S3 Endpoint Too
ECR stores container layers in S3. If you create ECR endpoints but not S3, image pulls will fail or route through NAT.
2. Cross-Region Endpoints Exist
Some services support cross-region endpoints. You can access S3 buckets in other regions through a local endpoint (with some limitations).
3. Endpoint Policies Don’t Replace IAM
Endpoint policies are an additional layer. The request must be allowed by both the endpoint policy AND the IAM policy.
Request → Endpoint Policy (Allow?) → IAM Policy (Allow?) → Success
│ │
└── Deny ──────────────────┴── Deny → Access Denied
4. Private DNS Doesn’t Work Across VPCs by Default
If VPC A has a Secrets Manager endpoint with private DNS, VPC B (peered) won’t use it. You need:
- Route 53 Resolver rules, or
- Endpoint in each VPC, or
- Centralised endpoints with Transit Gateway
5. Gateway Endpoints Don’t Support Endpoint Policies for All Actions
S3 gateway endpoint policies can’t restrict actions like CreateBucket. Some actions bypass the endpoint policy.
6. You Can See Endpoint Network Interfaces
aws ec2 describe-network-interfaces \
--filters "Name=interface-type,Values=vpc_endpoint" \
--query 'NetworkInterfaces[*].[NetworkInterfaceId,PrivateIpAddress,Description]' \
--output table
7. Endpoints Can Have Multiple ENIs (One Per AZ)
When you specify multiple subnets, you get one ENI per AZ. Traffic routes to the ENI in the same AZ as the source.
Troubleshooting
”Could not connect to the endpoint URL”
- Check security group allows 443 from source
- Verify private DNS is enabled
- Ensure VPC has DNS hostnames/support enabled
- Check route tables (for gateway endpoints)
Image Pull Failures with ECR
Ensure you have all three:
ecr.apiendpointecr.dkrendpoints3gateway endpoint
SSM Session Manager Not Working
Need all three endpoints:
ssmssmmessagesec2messages
Plus the instance needs the SSM agent and IAM permissions.
Endpoint Shows “Pending”
Interface endpoints for AWS services auto-accept. If stuck in “Pending”, check:
- Subnet has available IPs
- Security group exists
- Service is available in the region
Conclusion
VPC endpoints are essential for secure, private access to AWS services. Gateway endpoints for S3 and DynamoDB are free - always use them. Interface endpoints cost money but provide security benefits and eliminate NAT dependencies for private workloads.
Start with the basics: S3 gateway endpoint and interface endpoints for the services your applications actually use. Add endpoint policies for additional access control in sensitive environments.