Configuring AWS VPC Endpoints (PrivateLink) with Terraform
Learn how to set up Gateway and Interface VPC endpoints with Terraform so traffic to AWS services stays on the AWS network instead of traversing the public internet
A VPC endpoint lets resources in a private subnet reach AWS services without a NAT Gateway, Internet Gateway, or any traffic touching the public internet. This guide covers both endpoint types - Gateway and Interface - and how to write endpoint policies that restrict what they can be used for.
Prerequisites
- AWS CLI configured with appropriate permissions
- Terraform installed (version 1.0.0 or later)
- An existing VPC with private subnets and route tables
Project Structure
aws-vpc-endpoints-terraform/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
Gateway Endpoints (S3 and DynamoDB Only)
Gateway endpoints work by adding a route to your route table’s target list - there’s no ENI, no hourly charge, and no data processing charge. Only S3 and DynamoDB support this endpoint type; everything else uses an Interface endpoint (below).
# main.tf
provider "aws" {
region = var.aws_region
}
resource "aws_vpc_endpoint" "s3" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
tags = {
Name = "${var.project_name}-s3-endpoint"
}
}
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
tags = {
Name = "${var.project_name}-dynamodb-endpoint"
}
}
Interface Endpoints (Everything Else)
Interface endpoints create an Elastic Network Interface with a private IP in your subnets, fronted by a PrivateLink connection to the service. They cost an hourly fee per AZ plus a per-GB data processing charge, so add them only for services you actually call from private subnets.
resource "aws_security_group" "vpc_endpoints" {
name = "${var.project_name}-vpce-sg"
description = "Allow HTTPS from the VPC to interface endpoints"
vpc_id = var.vpc_id
ingress {
description = "HTTPS from VPC"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-vpce-sg"
}
}
locals {
interface_endpoints = toset([
"ec2",
"ecr.api",
"ecr.dkr",
"logs",
"secretsmanager",
"kms",
"sts",
])
}
resource "aws_vpc_endpoint" "interface" {
for_each = local.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 = "${var.project_name}-${each.value}-endpoint"
}
}
private_dns_enabled = true is what makes this transparent to your application code: it lets Route 53 resolve the service’s standard public hostname (e.g. secretsmanager.us-west-2.amazonaws.com) to the endpoint’s private IP, so nothing in your application or SDK configuration needs to change. It requires enable_dns_hostnames and enable_dns_support to both be true on the VPC itself.
ecr.dkr (for pulling image layers) needs s3 reachability too, since ECR stores layers in S3 - the Gateway endpoint above covers that as long as its route table includes the private subnets pulling images.
Restricting What an Endpoint Can Be Used For
An endpoint policy is a resource policy, similar in shape to an S3 bucket policy, attached to the endpoint itself. Without one, the endpoint allows any principal to call any action on any resource of that service (subject to normal IAM permissions) - the policy narrows that.
resource "aws_vpc_endpoint" "s3_restricted" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.aws_region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowSpecificBucketsOnly"
Effect = "Allow"
Principal = "*"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3:::${var.project_name}-*",
"arn:aws:s3:::${var.project_name}-*/*"
]
}
]
})
}
Combine this with an S3 bucket policy that requires aws:SourceVpce to match your endpoint’s ID (mirroring the pattern used for restricting bucket access to a specific VPC), and objects become unreachable except through this one endpoint - even with valid IAM credentials, a request from outside the VPC is denied.
Cross-Account: Exposing Your Own Service via PrivateLink
If you’re running a service behind a Network Load Balancer and want other VPCs (in the same account or a different one) to reach it privately, publish it as your own endpoint service:
resource "aws_vpc_endpoint_service" "internal_api" {
acceptance_required = true
network_load_balancer_arns = [aws_lb.internal_api.arn]
tags = {
Name = "${var.project_name}-internal-api-service"
}
}
resource "aws_vpc_endpoint_service_allowed_principal" "consumer_account" {
vpc_endpoint_service_id = aws_vpc_endpoint_service.internal_api.id
principal_arn = "arn:aws:iam::${var.consumer_account_id}:root"
}
The consuming account then creates a normal Interface aws_vpc_endpoint pointing at this service’s name (aws_vpc_endpoint_service.internal_api.service_name), and - because acceptance_required = true - the connection request sits pending until you accept it (via aws_vpc_endpoint_connection_accepter or the console) on the provider side.
Variables Configuration
variable "aws_region" {
description = "AWS region"
type = string
default = "us-west-2"
}
variable "project_name" {
description = "Project name"
type = string
}
variable "vpc_id" {
description = "VPC ID"
type = string
}
variable "vpc_cidr" {
description = "CIDR block of the VPC"
type = string
}
variable "private_subnet_ids" {
description = "Private subnet IDs for interface endpoint ENIs"
type = list(string)
}
variable "private_route_table_ids" {
description = "Route table IDs to add the gateway endpoint route to"
type = list(string)
}
variable "consumer_account_id" {
description = "AWS account ID allowed to connect to a self-published endpoint service"
type = string
default = ""
}
Best Practices
-
Cost
- Always use Gateway endpoints for S3 and DynamoDB - they’re free, so there’s no reason not to
- Only add Interface endpoints for services your private-subnet workloads actually call; each one is a per-AZ hourly cost
-
Security
- Attach an endpoint policy to every endpoint that handles sensitive data, not just the default “allow everything”
- Pair endpoint policies with resource-side conditions (
aws:SourceVpce) so the restriction can’t be bypassed by calling the API from outside the VPC - Scope the endpoint security group’s ingress to the VPC CIDR, not
0.0.0.0/0
-
DNS
- Verify
enable_dns_hostnames/enable_dns_supportaretrueon the VPC before relying onprivate_dns_enabled- without them, private DNS silently doesn’t resolve and traffic falls back to the public endpoint (or fails, in a fully private subnet)
- Verify
Conclusion
VPC endpoints are one of the highest-value, lowest-effort changes you can make to a private-subnet architecture: they remove a whole class of “does this need internet access” questions, cut NAT Gateway data-processing costs for AWS-service traffic, and give you a policy chokepoint you don’t get when traffic just goes out to the public API endpoint.
Remember to:
- Add Gateway endpoints for S3/DynamoDB by default in any VPC with private subnets
- Review the interface endpoint list against what services your workloads actually call
- Test with
private_dns_enabledbefore assuming an application “just works” through an endpoint