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.

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

  1. 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
  2. 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
  3. DNS

    • Verify enable_dns_hostnames/enable_dns_support are true on the VPC before relying on private_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)

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_enabled before assuming an application “just works” through an endpoint