When you need to share a service across multiple VPCs or AWS accounts, the naive approaches create problems. VPC peering connects entire networks, which means every resource in both VPCs can potentially reach every other resource. You cannot easily restrict which specific service is accessible. And as accounts multiply, the peering mesh grows into something no one can reason about.
AWS PrivateLink solves this with surgical precision. You expose a single service through an endpoint service. Consumers in other VPCs create an interface endpoint that connects to that specific service. Traffic stays entirely within the AWS network. The consumer VPC has no routing relationship with the provider VPC. In this article I will walk through setting up a PrivateLink service with Terraform and the access control patterns that matter in production.
Creating a VPC Endpoint Service
You expose your service through a Network Load Balancer. PrivateLink creates an endpoint service on top of the NLB that consumer accounts can connect to.
resource "aws_lb" "privatelink" {
name = "privatelink-nlb"
internal = true
load_balancer_type = "network"
subnets = var.private_subnet_ids
enable_deletion_protection = true
enable_cross_zone_load_balancing = true
tags = {
Environment = "production"
Purpose = "privatelink"
}
}
resource "aws_lb_listener" "privatelink" {
load_balancer_arn = aws_lb.privatelink.arn
port = 443
protocol = "TCP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.service.arn
}
}
resource "aws_vpc_endpoint_service" "main" {
acceptance_required = true
network_load_balancer_arns = [aws_lb.privatelink.arn]
allowed_principals = [
"arn:aws:iam::${var.consumer_account_id_1}:root",
"arn:aws:iam::${var.consumer_account_id_2}:root"
]
private_dns_name = "service.yourdomain.com"
tags = {
Name = "internal-api-endpoint-service"
Environment = "production"
}
}
acceptance_required = true means connection requests from consumers require explicit approval before traffic can flow. This is the correct setting for production. Without it, any principal in your allowed_principals list can connect without any approval workflow. With it, you review each connection request and approve or reject it, giving you full visibility and control over which specific consumer VPCs have connected to your service.
The private_dns_name enables consumers to access your service using a friendly DNS name rather than the endpoint-specific DNS that PrivateLink generates. After domain verification, consumers who create an interface endpoint to your service can resolve service.yourdomain.com to the private endpoint IP in their VPC.
Creating an Interface Endpoint (Consumer Side)
resource "aws_vpc_endpoint" "service" {
provider = aws.consumer_account
vpc_id = var.consumer_vpc_id
service_name = "com.amazonaws.vpce.us-east-1.vpce-svc-xxxxxxxxx"
vpc_endpoint_type = "Interface"
subnet_ids = var.consumer_private_subnet_ids
security_group_ids = [aws_security_group.endpoint.id]
private_dns_enabled = true
tags = {
Name = "internal-api-endpoint"
Environment = "production"
}
}
resource "aws_security_group" "endpoint" {
provider = aws.consumer_account
name = "privatelink-endpoint-sg"
vpc_id = var.consumer_vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [var.application_sg_id]
description = "HTTPS from application tier only"
}
tags = { Name = "privatelink-endpoint-sg" }
}
The interface endpoint creates elastic network interfaces in each specified subnet. Traffic from your application to the service name resolves to these ENI IPs, which are in your VPC’s address space. No traffic leaves your VPC to reach the provider service. The security group on the endpoint controls which resources in the consumer VPC can use it, which is a second layer of access control on top of the allowed_principals on the provider side.
Approving Connection Requests
resource "aws_cloudwatch_event_rule" "endpoint_connection_request" {
name = "privatelink-connection-request"
description = "Alert when a new PrivateLink connection is requested"
event_pattern = jsonencode({
source = ["aws.ec2"]
detail-type = ["AWS API Call via CloudTrail"]
detail = {
eventSource = ["ec2.amazonaws.com"]
eventName = ["CreateVpcEndpoint"]
}
})
}
resource "aws_cloudwatch_event_target" "notify_team" {
rule = aws_cloudwatch_event_rule.endpoint_connection_request.name
arn = aws_sns_topic.security_alerts.arn
}
With acceptance_required = true, the approval workflow is manual by default. You receive a notification, review the requesting account, and approve or reject via the console or CLI. For organizations with many consumer accounts, build an automation that receives the connection request event, validates the requesting account against an approved list, and calls AcceptVpcEndpointConnections automatically for approved accounts. This scales the approval process without requiring manual review for every new deployment.
Closing Thoughts
PrivateLink is the right connectivity pattern for sharing services across account boundaries. It gives you the isolation of separate VPCs with the convenience of direct private connectivity, without the routing complexity of VPC peering meshes. The producer controls exactly who can connect. The consumer controls which resources in their VPC can use the endpoint. Traffic never leaves the AWS network.
Use PrivateLink when you need to share a specific service across accounts. Use VPC peering when you need full network connectivity between trusted environments like production and shared services. Use Transit Gateway when you need to connect many VPCs in a hub-and-spoke topology. The right answer depends on what connectivity you actually need rather than what is easiest to set up initially.
Enjoy the cloud.
Osama
#AWS #PrivateLink #CloudSecurity #NetworkEngineering #CloudArchitecture #Terraform #InfrastructureAsCode #AmazonWebServices #SolutionsArchitect #CloudComputing #VPC #CrossAccount #ServiceConnectivity #TechBlog #CloudInfrastructure #ZeroTrust #Microservices #DevOps #SRE #CloudNetworking
Leave a Reply