AWS VPC Endpoints with Terraform: A Practical Guide to Private Routing and Endpoint Policies
Introduction: Why VPC Endpoints Exist at All
In a previous article, we explored VPC endpoints, the problems they solve, and the types of endpoints and their appropriate use cases.
By default, most AWS service APIs are accessible via public endpoints. Even EC2 instances without public IPs often send traffic to services like S3, Secrets Manager, or SSM through a NAT Gateway, traversing the public network before returning to the VPC.
VPC Endpoints solve this problem by enabling workloads inside a VPC to reach AWS services without leaving the private network, eliminating the need for Internet Gateways, NAT, or VPNs.
In this article, we provide a complete Terraform implementation of VPC endpoints, highlighting not just what to create, but also how traffic flows, where enforcement occurs, and why each design choice matters.
We will implement:
- A Gateway Endpoint for Amazon S3
- Interface Endpoints for Secrets Manager and Systems Manager
- Endpoint policies to enforce least privilege
- Supporting networking resources and security groups
Core Infrastructure: The Foundation Everything Depends On
VPC endpoints do not exist in isolation. They depend on DNS behavior, routing decisions, subnet placement, and security boundaries. We start with those fundamentals.
1. VPC: Network Boundary
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "main-vpc"
}
}
This VPC is the trust boundary for everything that follows.
Two flags here are non-negotiable:
enable_dns_supportenable_dns_hostnames
Interface endpoints rely on private DNS overrides. Without these settings, DNS will continue resolving AWS service names to public IPs, silently bypassing your endpoints.
This is one of the most common endpoint misconfigurations.
2. Private Subnet
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
tags = {
Name = "private-subnet"
}
}
Interface endpoints are implemented using Elastic Network Interfaces (ENIs). Those ENIs must live in a subnet.
This subnet:
- Hosts the endpoint ENIs
- Supplies private IP addresses for AWS service access
- Defines the AZ placement for interface endpoints
A key implication: interface endpoints are AZ-scoped resources, even though the service behind them is regional.
3. Route Table: How Gateway Endpoints Work at All
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "private-route-table"
}
}
Gateway endpoints do not create ENIs. They do not attach security groups. They do not receive IP addresses. They work by modifying route tables.
This route table will later receive a service prefix list route that says, effectively:
“Traffic destined for S3 IP ranges should be routed through the VPC endpoint, not the internet.”
Without this association, a gateway endpoint exists but does nothing.
4. Route Table Association
resource "aws_route_table_association" "private" {
subnet_id = aws_subnet.private.id
route_table_id = aws_route_table.private.id
}
Gateway endpoints work by modifying route tables, not by creating network interfaces.
This association ensures that the private subnet actually uses the route table where the S3 gateway endpoint injects its service prefix list route. That route tells AWS to send S3-bound traffic through the VPC endpoint instead of the internet.
Without this association, the gateway endpoint exists but is never used.
4. Security Group: Stateful Control for Interface Endpoints
Interface Endpoints create ENIs. ENIs require security groups.
resource "aws_security_group" "vpc_endpoints" {
name = "vpc-endpoints-sg"
description = "Security group for VPC Interface Endpoints"
vpc_id = aws_vpc.main.id
ingress {
description = "Allow HTTPS from within VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
}
egress {
description = "Allow return traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "vpc-endpoints-sg"
}
}
Interface endpoints are real network interfaces, and real network interfaces need security groups.
This security group:
- Allows HTTPS traffic
- Only from inside the VPC
- Acts as the first enforcement layer before endpoint policies or IAM
- Egress rules allow response traffic from the endpoint back to your resources, ensuring requests succeed over PrivateLink
If traffic is blocked here, it never reaches the AWS service.
Implementing VPC Endpoints: Two Models, Two Behaviors
AWS provides two fundamentally different endpoint architectures.
Before creating endpoints, the distinction matters.
- Gateway Endpoints
- S3, DynamoDB only
- Route table based
- No ENIs
- No security groups
- Free
- Interface Endpoints
- Most AWS services
- ENI-based
- Security groups required
- Hourly and data processing cost
- DNS-dependent
5. S3 Gateway Endpoint
resource "aws_vpc_endpoint" "s3_gateway" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [aws_route_table.private.id]
tags = {
Name = "s3-gateway-endpoint"
}
}
What actually happens
- AWS injects a prefix-list route for S3 into the route table
- Any traffic matching that prefix stays inside AWS
- No DNS changes occur
- No security groups are involved
There are no ENIs, no security groups, and no subnet placement decisions. The route table association is what makes this endpoint effective. If the route table is not associated with the subnet, this endpoint is unused.
6. Interface Endpoints (Secrets Manager & SSM)
Secrets Manager
resource "aws_vpc_endpoint" "secrets_manager" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.secretsmanager"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = [aws_subnet.private.id]
security_group_ids = [aws_security_group.vpc_endpoints.id]
tags = {
Name = "secrets-manager-endpoint"
}
}
This endpoint creates ENIs inside the subnet.
With private_dns_enabled = true:
secretsmanager.us-east-1.amazonaws.com- Resolves to a private IP inside the VPC
- Traffic never leaves AWS’s internal network
Security groups and endpoint policies now both apply.
Systems Manager
resource "aws_vpc_endpoint" "ssm" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.ssm"
vpc_endpoint_type = "Interface"
private_dns_enabled = true
subnet_ids = [aws_subnet.private.id]
security_group_ids = [aws_security_group.vpc_endpoints.id]
tags = {
Name = "ssm-endpoint"
}
}
This endpoint enables:
- Parameter Store access
- Session Manager
- Fleet management
All without public IPs or inbound SSH.
7. Endpoint Policies (Written Explicitly)
Endpoint policies are not IAM.
They are a network-level authorization filter.
S3 Gateway Endpoint Policy
resource "aws_vpc_endpoint_policy" "s3" {
vpc_endpoint_id = aws_vpc_endpoint.s3_gateway.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowSpecificBuckets"
Effect = "Allow"
Principal = "*"
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"s3:DeleteObject"
]
Resource = [
"arn:aws:s3:::app-data-bucket",
"arn:aws:s3:::app-data-bucket/*",
"arn:aws:s3:::logs-bucket",
"arn:aws:s3:::logs-bucket/*"
]
},
{
Sid = "DenyEverythingElse"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = "*"
}
]
})
}
This policy:
- Explicitly allows only known buckets
- Explicitly denies everything else
- Applies regardless of IAM permissions
Even an admin role cannot bypass a deny here.
Secrets Manager Endpoint Policy
resource "aws_vpc_endpoint_policy" "secrets_manager" {
vpc_endpoint_id = aws_vpc_endpoint.secrets_manager.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowReadOnly"
Effect = "Allow"
Principal = "*"
Action = [
"secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"
]
Resource = [
"arn:aws:secretsmanager:us-east-1:*:secret:app/*"
]
},
{
Sid = "DenyWrites"
Effect = "Deny"
Principal = "*"
Action = [
"secretsmanager:CreateSecret",
"secretsmanager:UpdateSecret",
"secretsmanager:DeleteSecret"
]
Resource = "*"
}
]
})
}
This enforces:
- Read-only access
- Scoped secret paths
- Explicit denial of writes
This is a common and correct pattern for runtime workloads.
SSM Endpoint Policy
resource "aws_vpc_endpoint_policy" "ssm" {
vpc_endpoint_id = aws_vpc_endpoint.ssm.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowParameterRead"
Effect = "Allow"
Principal = "*"
Action = [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
]
Resource = "arn:aws:ssm:us-east-1:*:parameter/app/*"
},
{
Sid = "DenySessionManager"
Effect = "Deny"
Principal = "*"
Action = [
"ssm:StartSession"
]
Resource = "*"
}
]
})
}
This policy:
- Allows Parameter Store reads
- Restricts Session Manager access
- Forces privileged access through a bastion role
This is service segmentation at the network boundary.
How Traffic Flows End to End
Interface Endpoint Flow
- Application resolves
secretsmanager.us-east-1.amazonaws.com - DNS returns private IP of endpoint ENI
- Traffic flows directly to ENI
- ENI forwards via PrivateLink
- Response returns the same way
No NAT.
No IGW.
No route table involvement.
Gateway Endpoint Flow
- Application resolves S3 hostname normally
- Route table matches S3 prefix list
- Traffic is redirected internally
- Endpoint policy is enforced
- IAM is evaluated last
Conclusion
Properly managing VPC endpoints with Terraform ensures that AWS workloads can access services like S3, Secrets Manager, and Systems Manager privately, securely, and predictably. By combining gateway and interface endpoints, security groups, and endpoint policies, you create a multi-layered enforcement model that separates network controls from IAM permissions.
This approach not only keeps traffic inside AWS’s private network but also provides auditable, code-managed infrastructure that is repeatable and maintainable. Correctly designed VPC endpoints reduce reliance on NAT gateways, prevent accidental exposure to the public internet, and give teams confidence that network traffic is truly private.
In short, Terraform allows you to define, enforce, and version your private connectivity, turning assumptions about network privacy into explicit, managed configurations.



