Quick take — A reusable Terraform module for aws_vpc_endpoint that provisions Gateway and Interface (PrivateLink) endpoints with private DNS, security groups, and least-privilege endpoint policies on AWS provider ~> 5.0. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "vpc_endpoint" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc-endpoint?ref=v1.0.0"
name = "..." # Logical name, used as the `Name` tag (1–255 chars).
vpc_id = "..." # VPC in which to create the endpoint (validated as `vpc-…
service = "..." # Short suffix (`s3`, `ssm`, `ecr.api`…) or fully-qualifi…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A VPC endpoint lets resources inside your VPC reach AWS services (and third-party PrivateLink services) over the AWS private network instead of traversing an internet gateway, NAT gateway, or public IP. There are two distinct flavours, and they behave nothing alike:
- Gateway endpoints (
vpc_endpoint_type = "Gateway") — only for S3 and DynamoDB. They install a prefix-list route into the route tables you specify. No ENIs, no security groups, no hourly or data-processing charge. They are effectively free routing entries. - Interface endpoints (
vpc_endpoint_type = "Interface", also called PrivateLink) — for almost every other service (SSM, ECR API/DKR, Secrets Manager, KMS, CloudWatch Logs, STS, SQS, etc.). They provision an elastic network interface with a private IP in each subnet you choose, attach security groups, and optionally enable private DNS so the service’s normal hostname resolves to the private IP. These are billed per-AZ-hour plus per-GB processed.
Wrapping aws_vpc_endpoint in a module matters because the two types need almost opposite arguments — Gateway wants route_table_ids and rejects subnet_ids/security_group_ids; Interface wants subnet_ids, security_group_ids and private_dns_enabled and rejects route_table_ids. A typical landing zone needs a dozen or more endpoints (one set per VPC, often the full “SSM bundle” so Session Manager works with no NAT). Hand-writing each one means repeating the type-specific dance, the DNS flag, the endpoint policy JSON, and the security group every time. This module hides that branching behind a handful of variables, ships a sane default-deny-then-allow endpoint policy, and emits the IDs and the auto-created DNS entries downstream resources need.
When to use it
- You are building a NAT-free or egress-restricted VPC and need SSM Session Manager, ECR pulls, CloudWatch Logs, and Secrets Manager to work over PrivateLink.
- You want S3/DynamoDB traffic to stay on-net for cost (no NAT data-processing fees) and for
aws:sourceVpce/aws:sourceVpcpolicy conditions on buckets and tables. - You must satisfy a compliance control that says “no data-plane traffic to AWS service endpoints over the public internet” (common in regulated / IRAP / PCI estates).
- You are standardising endpoints across many accounts and VPCs and want one audited module instead of copy-pasted resources.
Reach for something else when you only need a single ad-hoc endpoint in a throwaway sandbox (a raw resource is fine), or when the service you want simply does not offer a VPC endpoint — check the service’s endpoint support first.
Module structure
terraform-module-aws-vpc-endpoint/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
is_interface = var.endpoint_type == "Interface"
is_gateway = var.endpoint_type == "Gateway"
# service_name accepts either a short name ("s3", "ssm", "ecr.api")
# or a fully-qualified name ("com.amazonaws.eu-west-1.s3").
service_name = can(regex("^com\\.amazonaws\\.", var.service))
? var.service
: "com.amazonaws.${data.aws_region.current.name}.${var.service}"
base_tags = merge(
{
Name = var.name
ManagedBy = "terraform"
Module = "terraform-module-aws-vpc-endpoint"
},
var.tags,
)
}
data "aws_region" "current" {}
resource "aws_vpc_endpoint" "this" {
vpc_id = var.vpc_id
service_name = local.service_name
vpc_endpoint_type = var.endpoint_type
auto_accept = var.auto_accept
# Interface-only arguments. For Gateway endpoints these stay null/empty,
# which AWS requires.
subnet_ids = local.is_interface ? var.subnet_ids : null
security_group_ids = local.is_interface ? var.security_group_ids : null
private_dns_enabled = local.is_interface ? var.private_dns_enabled : null
ip_address_type = local.is_interface ? var.ip_address_type : null
# Gateway-only argument: which route tables receive the service prefix list.
route_table_ids = local.is_gateway ? var.route_table_ids : null
# Optional least-privilege resource policy on the endpoint itself.
policy = var.policy
dynamic "dns_options" {
for_each = local.is_interface && var.dns_record_ip_type != null ? [1] : []
content {
dns_record_ip_type = var.dns_record_ip_type
private_dns_only_for_inbound_resolver_endpoint = var.private_dns_only_for_inbound_resolver_endpoint
}
}
tags = local.base_tags
timeouts {
create = var.create_timeout
update = var.update_timeout
delete = var.delete_timeout
}
}
variables.tf
variable "name" {
description = "Logical name for the endpoint, used as the Name tag (e.g. \"prod-ssm\")."
type = string
validation {
condition = length(var.name) > 0 && length(var.name) <= 255
error_message = "name must be between 1 and 255 characters."
}
}
variable "vpc_id" {
description = "ID of the VPC in which to create the endpoint."
type = string
validation {
condition = can(regex("^vpc-[0-9a-f]{8,17}$", var.vpc_id))
error_message = "vpc_id must be a valid VPC ID (vpc-xxxxxxxx)."
}
}
variable "service" {
description = <<-EOT
Service to connect to. Either a short suffix ("s3", "dynamodb", "ssm",
"ssmmessages", "ec2messages", "ecr.api", "ecr.dkr", "logs", "secretsmanager",
"kms", "sts") which is expanded to com.amazonaws.<region>.<service>, or a
fully-qualified service name for third-party PrivateLink services.
EOT
type = string
}
variable "endpoint_type" {
description = "Endpoint type: \"Gateway\" (S3/DynamoDB only) or \"Interface\" (PrivateLink)."
type = string
default = "Interface"
validation {
condition = contains(["Gateway", "Interface"], var.endpoint_type)
error_message = "endpoint_type must be either \"Gateway\" or \"Interface\"."
}
}
variable "subnet_ids" {
description = "Interface endpoints only: subnets (ideally one per AZ) in which to place endpoint ENIs."
type = list(string)
default = []
}
variable "security_group_ids" {
description = "Interface endpoints only: security groups controlling access to the endpoint ENIs."
type = list(string)
default = []
}
variable "route_table_ids" {
description = "Gateway endpoints only: route tables that receive the service prefix-list route."
type = list(string)
default = []
}
variable "private_dns_enabled" {
description = "Interface endpoints only: resolve the service's default DNS name to the endpoint's private IPs."
type = bool
default = true
}
variable "ip_address_type" {
description = "Interface endpoints only: IP address type for the ENIs (\"ipv4\", \"ipv6\", or \"dualstack\")."
type = string
default = "ipv4"
validation {
condition = contains(["ipv4", "ipv6", "dualstack"], var.ip_address_type)
error_message = "ip_address_type must be one of \"ipv4\", \"ipv6\", or \"dualstack\"."
}
}
variable "dns_record_ip_type" {
description = "Interface endpoints only: DNS record IP type (\"ipv4\", \"ipv6\", \"dualstack\", \"service-defined\"). Null omits the dns_options block."
type = string
default = null
validation {
condition = var.dns_record_ip_type == null || contains(
["ipv4", "ipv6", "dualstack", "service-defined"], coalesce(var.dns_record_ip_type, "ipv4")
)
error_message = "dns_record_ip_type must be null or one of ipv4, ipv6, dualstack, service-defined."
}
}
variable "private_dns_only_for_inbound_resolver_endpoint" {
description = "Interface endpoints only: restrict private DNS to traffic from an inbound Resolver endpoint."
type = bool
default = false
}
variable "policy" {
description = "Optional IAM resource policy (JSON) attached to the endpoint. Null applies the AWS full-access default."
type = string
default = null
}
variable "auto_accept" {
description = "Accept the endpoint when the service is in the same AWS account."
type = bool
default = true
}
variable "create_timeout" {
description = "Timeout for endpoint creation."
type = string
default = "10m"
}
variable "update_timeout" {
description = "Timeout for endpoint updates."
type = string
default = "10m"
}
variable "delete_timeout" {
description = "Timeout for endpoint deletion."
type = string
default = "10m"
}
variable "tags" {
description = "Additional tags merged onto the endpoint."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "ID of the VPC endpoint."
value = aws_vpc_endpoint.this.id
}
output "arn" {
description = "ARN of the VPC endpoint."
value = aws_vpc_endpoint.this.arn
}
output "service_name" {
description = "Fully-qualified service name the endpoint connects to."
value = aws_vpc_endpoint.this.service_name
}
output "state" {
description = "Lifecycle state of the endpoint (e.g. available)."
value = aws_vpc_endpoint.this.state
}
output "network_interface_ids" {
description = "ENI IDs created for an Interface endpoint (empty for Gateway)."
value = aws_vpc_endpoint.this.network_interface_ids
}
output "dns_entry" {
description = "DNS entries (hosted_zone_id + dns_name) auto-created for an Interface endpoint."
value = aws_vpc_endpoint.this.dns_entry
}
output "prefix_list_id" {
description = "Prefix list ID associated with a Gateway endpoint (useful for security group rules)."
value = aws_vpc_endpoint.this.prefix_list_id
}
How to use it
The example below stands up the S3 Gateway endpoint (free, routed) and the SSM Interface endpoint (so Session Manager works with no NAT), then shows a downstream consumer using an output.
# Security group for interface endpoints: allow HTTPS from the VPC CIDR only.
resource "aws_security_group" "endpoints" {
name_prefix = "vpce-"
description = "Allow 443 from within 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 = [data.aws_vpc.selected.cidr_block]
}
}
# Gateway endpoint for S3 — installs the prefix-list route, costs nothing.
module "s3_endpoint" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc-endpoint?ref=v1.0.0"
name = "prod-s3"
vpc_id = var.vpc_id
service = "s3"
endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids
# Restrict to this account's buckets only.
policy = data.aws_iam_policy_document.s3_endpoint.json
tags = { Environment = "prod" }
}
# Interface (PrivateLink) endpoint for SSM core.
module "ssm_endpoint" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc-endpoint?ref=v1.0.0"
name = "prod-ssm"
vpc_id = var.vpc_id
service = "ssm"
endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.endpoints.id]
private_dns_enabled = true
tags = { Environment = "prod" }
}
# Downstream: reference the Gateway endpoint's prefix list in another
# security group so instances can egress to S3 without 0.0.0.0/0.
resource "aws_security_group_rule" "app_to_s3" {
type = "egress"
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [module.s3_endpoint.prefix_list_id]
security_group_id = aws_security_group.app.id
description = "Egress to S3 via gateway endpoint"
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/vpc_endpoint/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-vpc-endpoint?ref=v1.0.0"
}
inputs = {
name = "..."
vpc_id = "..."
service = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/vpc_endpoint && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Logical name, used as the Name tag (1–255 chars). |
vpc_id |
string |
— | Yes | VPC in which to create the endpoint (validated as vpc-…). |
service |
string |
— | Yes | Short suffix (s3, ssm, ecr.api…) or fully-qualified service name. |
endpoint_type |
string |
"Interface" |
No | "Gateway" (S3/DynamoDB) or "Interface" (PrivateLink). |
subnet_ids |
list(string) |
[] |
No | Interface only: subnets for endpoint ENIs (one per AZ). |
security_group_ids |
list(string) |
[] |
No | Interface only: security groups guarding the ENIs. |
route_table_ids |
list(string) |
[] |
No | Gateway only: route tables receiving the prefix-list route. |
private_dns_enabled |
bool |
true |
No | Interface only: resolve the service’s default hostname to private IPs. |
ip_address_type |
string |
"ipv4" |
No | Interface only: ipv4, ipv6, or dualstack. |
dns_record_ip_type |
string |
null |
No | Interface only: DNS record IP type; null omits the dns_options block. |
private_dns_only_for_inbound_resolver_endpoint |
bool |
false |
No | Interface only: limit private DNS to inbound Resolver endpoint traffic. |
policy |
string |
null |
No | JSON resource policy on the endpoint; null uses AWS full-access default. |
auto_accept |
bool |
true |
No | Auto-accept when the service is in the same account. |
create_timeout |
string |
"10m" |
No | Create timeout. |
update_timeout |
string |
"10m" |
No | Update timeout. |
delete_timeout |
string |
"10m" |
No | Delete timeout. |
tags |
map(string) |
{} |
No | Additional tags merged onto the endpoint. |
Outputs
| Name | Description |
|---|---|
id |
ID of the VPC endpoint. |
arn |
ARN of the VPC endpoint. |
service_name |
Fully-qualified service name the endpoint connects to. |
state |
Lifecycle state (e.g. available). |
network_interface_ids |
ENI IDs for an Interface endpoint (empty for Gateway). |
dns_entry |
Auto-created DNS entries (hosted_zone_id + dns_name) for Interface endpoints. |
prefix_list_id |
Prefix list ID for a Gateway endpoint, for security-group rules. |
Enterprise scenario
A bank runs a fleet of private-subnet EC2 patch hosts across 40 workload accounts with no NAT gateways to keep the data plane off the internet and cut NAT data-processing costs. Each account’s network pipeline calls this module to deploy the full Session Manager bundle — ssm, ssmmessages, ec2messages as Interface endpoints plus an s3 Gateway endpoint for patch-baseline downloads — all with private_dns_enabled = true so the standard AWS CLI and SSM agent resolve to private IPs unchanged. The s3_endpoint.prefix_list_id output feeds the instances’ egress security-group rule, and an aws:sourceVpce condition on the patch buckets guarantees they can only be read through the endpoint.
Best practices
- Pick the right type, and don’t pay for Gateway. S3 and DynamoDB should be Gateway endpoints — they are free and route-based. Using Interface endpoints for them adds needless per-AZ-hour and per-GB charges. Reserve Interface endpoints for services that have no Gateway option.
- Lock the security group to 443 from the VPC CIDR, not
0.0.0.0/0. Interface endpoint ENIs only ever serve HTTPS; a tight ingress rule is the primary network control on PrivateLink traffic. - Attach a least-privilege endpoint policy. The AWS default is full access. Scope
policywithaws:PrincipalOrgID, specific resource ARNs, or account conditions so a compromised instance can’t pivot to arbitrary buckets, secrets, or parameters through the endpoint. - One subnet per AZ for resilience and to avoid cross-AZ data charges. Place ENIs in every AZ your workloads run in; in-region traffic then stays in-AZ where possible and survives an AZ outage.
- Enable private DNS deliberately, and only once per service per VPC. With
private_dns_enabled = truethe service hostname is overridden VPC-wide — creating a second endpoint for the same service in the same VPC with private DNS on will conflict. - Tag and name consistently (
<env>-<service>, e.g.prod-ssm) so the dozens of endpoints a landing zone accrues stay attributable in Cost Explorer and discoverable in the console.