Skip to content
Back to blog AWS VPC Endpoints - Keep Your Traffic Off the Internet

AWS VPC Endpoints - Keep Your Traffic Off the Internet

AWSNetworking

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”

  1. Check security group allows 443 from source
  2. Verify private DNS is enabled
  3. Ensure VPC has DNS hostnames/support enabled
  4. Check route tables (for gateway endpoints)

Image Pull Failures with ECR

Ensure you have all three:

  • ecr.api endpoint
  • ecr.dkr endpoint
  • s3 gateway endpoint

SSM Session Manager Not Working

Need all three endpoints:

  • ssm
  • ssmmessages
  • ec2messages

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.


References

Found this helpful?

Comments