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…
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