IaC AWS

Terraform Module: AWS VPC Endpoint — keep AWS API traffic off the public internet

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:

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

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 configlive/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 configlive/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

TerraformAWSVPC EndpointModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading