← Back to list

aws vpc-endpoint: centralized

VPC Endpoints allow AWS resources (e.g. EC2 instances, EKS nodes) inside a private VPC to communicate with supported AWS services (e.g…

John Zen · 2026-07-25 11:28 · 0 claps · 2.8 min read
#aws #aws-vpc-endpoints #aws-route53
Open on Medium ↗
Wiki topics: FT · Fine-tuning & Adaptation ☁️ · DevOps & Cloud

aws vpc-endpoint: centralized

VPC Endpoints allow AWS resources (e.g. EC2 instances, EKS nodes) inside a private VPC to communicate with supported AWS services (e.g., SSM, ECR, S3 and etc) directly and privately.

Benefits:

  • security: keep traffic within AWS network.
  • reduce cost; NAT Gwateway cost vs vpc-endpoint charge.
  • faster as traffic does not transverse internet.

In a multi-VPC environment, creating individual VPC endpoints in every spoke VPC creates multiple hourly running costs and operational complexity. Having one set of vpc-endpoints at a centralized vpc remove duplicates. Reference.

Gateway endpoints (e.g. s3) cannot cross Transit Gateway or VPC peering; thus they still need to one set per vpc.

Without VPC-endpoint:

EC2 Instance → NAT Gateway ($$/GB) → Internet Gateway → Public AWS Endpoint

With VPC-endpoint

EC2 Instance → ENI (e.g. 10.x.x.x) → Private AWS Network Backbone

With centralized VPC-endpoint

EC2 Instance 
# DNS resolution
→ (e.g. ssm.ap-southeast-2.amazonaws.com)
→ resolved at private hosted zone at centralized vpc
→ return ENI at centralized VPC

# traffic
ec2 in spoke VPC
→ TGW / Peering 
→ ENI at central vpc 
  # e.g.
  ssm (e.g. 10.100.0.10)
  ssmmessages (e.g. 10.100.0.20)

→ Private AWS Network Backbone

Setup

Create VPC interface endpoints at centralized vpc.

  • set private_dns_enabled = false

For each vpc-endpoint, create one private hosted-zone and one alias A record pointing to VPC endpoint’s regional DNS target.

Create a Route53 profile

  • attach all private hosted zones for vpc-endpoints
  • associate with all spoke vpcs

Create

vpc-endpoints

resource "aws_vpc_endpoint" "interface" {
  for_each = toset(["ec2", "ssm"])

  vpc_id              = "${central-vpc-id}"
  service_name        = "com.amazonaws.${var.region}.${each.value}"
  vpc_endpoint_type   = "Interface"
  private_dns_enabled = false
  subnet_ids          = "${private-subnets-ids}"
  security_group_ids  = "${sg-allowing all-spoke-vpc-ip}"
}

private hosted-zone

# -------------------------------------------------------
# Derive hosted zone name and record name from
# the service name of each endpoint
#
#    Logic:
#    com.amazonaws.ap-southeast-2.ec2
#      → split by "."  = ["com","amazonaws","ap-southeast-2","ec2"]
#      → drop first 2  = ["ap-southeast-2","ec2"]
#      → reverse       = ["ec2","ap-southeast-2"]
#      → join + suffix = "ec2.ap-southeast-2.amazonaws.com"
#
#    com.amazonaws.ap-southeast-2.ecr.api
#      → split by "."  = ["com","amazonaws","ap-southeast-2","ecr","api"]
#      → drop first 2  = ["ap-southeast-2","ecr","api"]
#      → reverse       = ["api","ecr","ap-southeast-2"]
#      → join + suffix = "api.ecr.ap-southeast-2.amazonaws.com"
# -------------------------------------------------------

locals {

  endpoints = {
    for id, ep in data.aws_vpc_endpoint.details :

    # Use last part of service name as key e.g. "ec2", "ecr.api"
    join(".", slice(
      split(".", ep.service_name),
      3,                              # drop "com.amazonaws.{region}"
      length(split(".", ep.service_name))
    )) => {

      endpoint_id  = id
      service_name = ep.service_name

      # Reverse the service name parts to get DNS name
      # e.g. com.amazonaws.ap-southeast-2.ec2 → ec2.ap-southeast-2.amazonaws.com
      dns_name = join(".", concat(
        reverse(slice(
          split(".", ep.service_name),
          3,                          # service parts only e.g. ["ec2"] or ["ecr","api"]
          length(split(".", ep.service_name))
        )),
        ["ap-southeast-2", "amazonaws", "com"]  # fixed suffix
      ))

      # First DNS entry from the endpoint = the alias target
      alias_dns_name = ep.dns_entry[0].dns_name

      # Hosted zone ID from the endpoint DNS entry
      alias_hosted_zone_id = ep.dns_entry[0].hosted_zone_id
    }
    # Only process endpoints that have DNS entries
    if length(ep.dns_entry) > 0
  }
}
resource "aws_route53_zone" "private" {
  for_each = local.endpoints

  name    = each.value.dns_name
  comment = "Centralized ${each.key} VPC endpoint DNS"

  vpc {
    vpc_id = var.security_vpc_id
  }

  tags = {
    Name    = "private-hosted-zone-${each.key}-centralized"
    Service = each.key
  }

  lifecycle {
    ignore_changes  = [vpc]
  }
}
resource "aws_route53_record" "this" {
  for_each = local.endpoints

  zone_id = aws_route53_zone.private[each.key].zone_id
  name    = each.value.dns_name
  type    = "A"

  alias {
    name                   = each.value.alias_dns_name
    zone_id                = each.value.alias_hosted_zone_id
    evaluate_target_health = true
  }

  depends_on = [aws_route53_zone.private]
}

route53 profile

resource "aws_route53profiles_profile" "centralized-vpc-endpoint" {
  name = "centralized-vpc-endpoint"

  tags = {
    Name = "centralized-vpc-endpoint"
  }
}
# Associate all private hosted-zones with the Route 53 Profile
resource "aws_route53profiles_resource_association" "hosted_zones" {
  for_each = local.endpoints

  name         = "private-hosted-zone-${each.key}"
  profile_id   = aws_route53profiles_profile.centralized-vpc-endpoint.id
  resource_arn = aws_route53_zone.private[each.key].arn
}
# Associate each spoke VPC with the Route 53 Profile
resource "aws_route53profiles_association" "spoke_vpcs" {
  for_each = var.spoke_vpc_ids

  name        = "vpc-spoke-${each.key}"
  profile_id  = aws_route53profiles_profile.centralized.id
  resource_id = each.value
}

Verify

In an ec2 of a spoke vpc

nslookup ssm.ap-southeast-2.amazonaws.com
nslookup api.ecr.ap-southeast-2.amazonaws.com
nslookup dkr.ecr.ap-southeast-2.amazonaws.com

# should return private ip of centralized vpc

At cloudshell or in a jump-host

aws ssm describe-instance-information \
  --region ap-southeast-2 \
  --query 'InstanceInformationList[].{ID:InstanceId,Ping:PingStatus,IP:IPAddress}' \
  --output table

# Start SSM session
aws ssm start-session \
  --target i-01234567890 \
  --region ap-southeast-2

메타데이터
post_id
2c5bdc3c2aa7
slug
aws-vpc-endpoint-centralized-2c5bdc3c2aa7
url
https://medium.com/@john.shaw.zen/aws-vpc-endpoint-centralized-2c5bdc3c2aa7
canonical_url
https://medium.com/@john.shaw.zen/aws-vpc-endpoint-centralized-2c5bdc3c2aa7
author_url
https://medium.com/@john.shaw.zen
status
ok
fetched_at
2026-09-12 04:32:23