Skip to content
Back to blog AWS PrivateLink Deep Dive: Private Connectivity Patterns

AWS PrivateLink Deep Dive: Private Connectivity Patterns

AWSNetworking

AWS PrivateLink Deep Dive: Private Connectivity Patterns

PrivateLink enables private connectivity to AWS services and your own services without traversing the public internet. Traffic stays on the AWS backbone.

This guide covers VPC Endpoints, Endpoint Services, cross-account patterns, and Terraform automation.

TL;DR

  • Interface Endpoints = ENIs for AWS services (S3, EC2, etc.)
  • Gateway Endpoints = route table entries (S3, DynamoDB only)
  • Endpoint Services = expose your services via PrivateLink
  • Cross-account sharing for multi-account architectures
  • Full Terraform examples included

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                         Consumer VPC                             │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │                                                              ││
│  │   ┌──────────┐        ┌──────────────────┐                  ││
│  │   │   App    │───────▶│  VPC Endpoint    │                  ││
│  │   │          │        │  (Interface)     │                  ││
│  │   └──────────┘        └────────┬─────────┘                  ││
│  │                                │                             ││
│  └────────────────────────────────┼─────────────────────────────┘│
└───────────────────────────────────┼─────────────────────────────┘
                                    │ Private (AWS backbone)
┌───────────────────────────────────┼─────────────────────────────┐
│                         Provider VPC                             │
│  ┌────────────────────────────────┼─────────────────────────────┐│
│  │                                ▼                              ││
│  │                   ┌──────────────────┐                       ││
│  │                   │  Endpoint Service │                      ││
│  │                   │      (NLB)        │                      ││
│  │                   └────────┬─────────┘                       ││
│  │                            │                                  ││
│  │                   ┌────────▼─────────┐                       ││
│  │                   │    Your Service   │                      ││
│  │                   └──────────────────┘                       ││
│  └──────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘

VPC Endpoints for AWS Services

Interface Endpoints

# Interface endpoint for ECR
resource "aws_vpc_endpoint" "ecr_api" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${var.region}.ecr.api"
  vpc_endpoint_type   = "Interface"
  
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.vpc_endpoints.id]
  
  private_dns_enabled = true  # Use AWS service DNS names
  
  tags = {
    Name = "ecr-api-endpoint"
  }
}

resource "aws_vpc_endpoint" "ecr_dkr" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${var.region}.ecr.dkr"
  vpc_endpoint_type   = "Interface"
  
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.vpc_endpoints.id]
  
  private_dns_enabled = true
  
  tags = {
    Name = "ecr-dkr-endpoint"
  }
}

# Security group for endpoints
resource "aws_security_group" "vpc_endpoints" {
  name_prefix = "vpc-endpoints-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.main.cidr_block]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Gateway Endpoints (S3 and DynamoDB)

# Gateway endpoint for S3
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"
  
  route_table_ids = aws_route_table.private[*].id
  
  tags = {
    Name = "s3-endpoint"
  }
}

# Gateway endpoint for DynamoDB
resource "aws_vpc_endpoint" "dynamodb" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.region}.dynamodb"
  vpc_endpoint_type = "Gateway"
  
  route_table_ids = aws_route_table.private[*].id
  
  tags = {
    Name = "dynamodb-endpoint"
  }
}

Common Endpoints Module

# modules/vpc-endpoints/main.tf
variable "vpc_id" {}
variable "subnet_ids" {}
variable "security_group_id" {}
variable "route_table_ids" {}
variable "region" {}

locals {
  interface_endpoints = [
    "ecr.api",
    "ecr.dkr",
    "logs",
    "monitoring",
    "ssm",
    "ssmmessages",
    "ec2messages",
    "secretsmanager",
    "kms",
    "sts",
    "elasticloadbalancing",
    "autoscaling",
    "eks",
  ]
  
  gateway_endpoints = [
    "s3",
    "dynamodb",
  ]
}

resource "aws_vpc_endpoint" "interface" {
  for_each = toset(local.interface_endpoints)
  
  vpc_id              = var.vpc_id
  service_name        = "com.amazonaws.${var.region}.${each.value}"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = var.subnet_ids
  security_group_ids  = [var.security_group_id]
  private_dns_enabled = true
  
  tags = {
    Name = "${replace(each.value, ".", "-")}-endpoint"
  }
}

resource "aws_vpc_endpoint" "gateway" {
  for_each = toset(local.gateway_endpoints)
  
  vpc_id            = var.vpc_id
  service_name      = "com.amazonaws.${var.region}.${each.value}"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = var.route_table_ids
  
  tags = {
    Name = "${each.value}-endpoint"
  }
}

Create Your Own Endpoint Service

Expose your service via PrivateLink:

# Network Load Balancer (required for endpoint service)
resource "aws_lb" "api" {
  name               = "api-nlb"
  internal           = true
  load_balancer_type = "network"
  subnets            = aws_subnet.private[*].id

  enable_cross_zone_load_balancing = true
}

resource "aws_lb_target_group" "api" {
  name     = "api-tg"
  port     = 8080
  protocol = "TCP"
  vpc_id   = aws_vpc.main.id

  health_check {
    enabled             = true
    interval            = 30
    port                = "traffic-port"
    protocol            = "TCP"
    healthy_threshold   = 3
    unhealthy_threshold = 3
  }
}

resource "aws_lb_listener" "api" {
  load_balancer_arn = aws_lb.api.arn
  port              = 443
  protocol          = "TCP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api.arn
  }
}

# VPC Endpoint Service
resource "aws_vpc_endpoint_service" "api" {
  acceptance_required        = true  # Manual approval for consumers
  network_load_balancer_arns = [aws_lb.api.arn]
  
  allowed_principals = [
    "arn:aws:iam::111111111111:root",  # Account 1
    "arn:aws:iam::222222222222:root",  # Account 2
  ]
  
  tags = {
    Name = "api-endpoint-service"
  }
}

output "endpoint_service_name" {
  value = aws_vpc_endpoint_service.api.service_name
}

Consumer Side

# In consumer account
resource "aws_vpc_endpoint" "provider_api" {
  vpc_id              = aws_vpc.consumer.id
  service_name        = "com.amazonaws.vpce.eu-west-2.vpce-svc-xxxxxxxxx"
  vpc_endpoint_type   = "Interface"
  
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.endpoint.id]
  
  private_dns_enabled = false  # Can't use with cross-account
  
  tags = {
    Name = "provider-api-endpoint"
  }
}

# Create Route53 private hosted zone for nice DNS
resource "aws_route53_zone" "provider_api" {
  name = "api.provider.internal"
  
  vpc {
    vpc_id = aws_vpc.consumer.id
  }
}

resource "aws_route53_record" "provider_api" {
  zone_id = aws_route53_zone.provider_api.zone_id
  name    = "api.provider.internal"
  type    = "A"
  
  alias {
    name                   = aws_vpc_endpoint.provider_api.dns_entry[0].dns_name
    zone_id                = aws_vpc_endpoint.provider_api.dns_entry[0].hosted_zone_id
    evaluate_target_health = true
  }
}

Cross-Account Patterns

                    ┌─────────────────────┐
                    │    Shared Services  │
                    │       Account       │
                    │  ┌───────────────┐  │
                    │  │ Endpoint Svc  │  │
                    │  │  (API, Auth)  │  │
                    │  └───────────────┘  │
                    └─────────┬───────────┘

          ┌───────────────────┼───────────────────┐
          │                   │                   │
          ▼                   ▼                   ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│   Workload A    │ │   Workload B    │ │   Workload C    │
│    Account      │ │    Account      │ │    Account      │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ VPC Endpoint│ │ │ │ VPC Endpoint│ │ │ │ VPC Endpoint│ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘

Terraform for Multi-Account

# Provider account - endpoint service
resource "aws_vpc_endpoint_service" "shared" {
  acceptance_required        = false  # Auto-accept from allowed accounts
  network_load_balancer_arns = [aws_lb.shared.arn]
  
  allowed_principals = var.consumer_account_arns
}

# Store service name in SSM for consumers
resource "aws_ssm_parameter" "endpoint_service_name" {
  name  = "/shared/endpoint-service-name"
  type  = "String"
  value = aws_vpc_endpoint_service.shared.service_name
}

# Consumer account - using data source
data "aws_ssm_parameter" "endpoint_service_name" {
  provider = aws.shared
  name     = "/shared/endpoint-service-name"
}

resource "aws_vpc_endpoint" "shared_service" {
  vpc_id            = aws_vpc.main.id
  service_name      = data.aws_ssm_parameter.endpoint_service_name.value
  vpc_endpoint_type = "Interface"
  
  subnet_ids         = aws_subnet.private[*].id
  security_group_ids = [aws_security_group.endpoint.id]
}

SaaS Integration Patterns

Connect to third-party SaaS via PrivateLink:

# Example: Datadog PrivateLink
resource "aws_vpc_endpoint" "datadog" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef"
  vpc_endpoint_type   = "Interface"
  
  subnet_ids          = aws_subnet.private[*].id
  security_group_ids  = [aws_security_group.datadog.id]
  
  private_dns_enabled = false
}

# Private hosted zone for Datadog
resource "aws_route53_zone" "datadog" {
  name = "datadoghq.com"
  
  vpc {
    vpc_id = aws_vpc.main.id
  }
}

resource "aws_route53_record" "datadog" {
  for_each = {
    "agent-http-intake.logs" = aws_vpc_endpoint.datadog.dns_entry[0]
    "api"                     = aws_vpc_endpoint.datadog.dns_entry[0]
  }
  
  zone_id = aws_route53_zone.datadog.zone_id
  name    = "${each.key}.datadoghq.com"
  type    = "A"
  
  alias {
    name                   = each.value.dns_name
    zone_id                = each.value.hosted_zone_id
    evaluate_target_health = true
  }
}

Endpoint Policies

Restrict what can be done through an endpoint:

resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = aws_route_table.private[*].id
  
  # Restrict to specific buckets
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = "*"
        Action    = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
        Resource  = [
          "arn:aws:s3:::company-data-bucket",
          "arn:aws:s3:::company-data-bucket/*",
          "arn:aws:s3:::company-logs-bucket",
          "arn:aws:s3:::company-logs-bucket/*",
        ]
      },
      {
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource  = "*"
        Condition = {
          StringNotEquals = {
            "aws:PrincipalAccount" = var.account_id
          }
        }
      }
    ]
  })
}

Cost Optimization

ENDPOINT TYPE       COST
=============       ====
Gateway (S3/DDB)    Free
Interface           $0.01/hr per AZ + $0.01/GB processed

Tips:

  • Use Gateway endpoints for S3/DynamoDB (free)
  • Consolidate Interface endpoints across fewer AZs for dev
  • Use endpoint policies to prevent data exfiltration

Troubleshooting

Endpoint not resolving:

# Check private DNS is enabled
aws ec2 describe-vpc-endpoints --vpc-endpoint-ids vpce-xxx

# Check DNS resolution
dig +short ec2.eu-west-2.amazonaws.com

# Should return private IP if working

Connection timeout:

# Check security group allows inbound 443
aws ec2 describe-security-groups --group-ids sg-xxx

# Check route tables include endpoint
aws ec2 describe-route-tables --route-table-ids rtb-xxx

References

======================================== AWS PrivateLink + VPC Endpoints

Private connectivity. No internet exposure.

Found this helpful?

Comments