Skip to main content
aws in the trenches advanced cloud engineering for senior developers

Transit Gateway, PrivateLink, and Multi-VPC Architectures

5 min read Chapter 14 of 21

Transit Gateway, PrivateLink, and Multi-VPC Architectures

A single VPC works until you need environment isolation (dev/staging/prod), team isolation (platform/payments/logistics), or compliance boundaries (PCI scope limitation). Then you face the interconnect problem: how do 15 VPCs communicate without O(n²) peering connections?

The Interconnect Decision Framework

MethodMax ConnectionsBandwidthCostUse Case
VPC Peering125 per VPC10+ Gbps$0.01/GB (cross-AZ)Direct 1:1 connection, low latency
Transit Gateway5,000 attachments50 Gbps$0.05/hour + $0.02/GBHub-and-spoke, centralized routing
PrivateLinkUnlimitedScales with NLB$0.01/hour + $0.01/GBService-to-service, no route exposure
VPN over TGWPer attachment1.25 Gbps per tunnel$0.05/hourOn-premises connectivity

Transit Gateway: The Network Hub

Transit Gateway Topology

Transit Gateway acts as a regional network hub. VPCs, VPNs, and Direct Connect gateways attach to it, and route tables control traffic flow between attachments:

import boto3

ec2 = boto3.client('ec2')

# Create Transit Gateway
tgw_response = ec2.create_transit_gateway(
    Description='Central network hub',
    Options={
        'AmazonSideAsn': 64512,
        'AutoAcceptSharedAttachments': 'disable',  # Require explicit acceptance
        'DefaultRouteTableAssociation': 'disable',  # We'll manage route tables manually
        'DefaultRouteTablePropagation': 'disable',
        'DnsSupport': 'enable',
        'VpnEcmpSupport': 'enable',
        'MulticastSupport': 'disable'
    }
)
tgw_id = tgw_response['TransitGateway']['TransitGatewayId']

# Create separate route tables for network segmentation
# Prod VPCs can reach each other and shared services, but NOT dev VPCs
prod_rt = ec2.create_transit_gateway_route_table(TransitGatewayId=tgw_id)
dev_rt = ec2.create_transit_gateway_route_table(TransitGatewayId=tgw_id)
shared_rt = ec2.create_transit_gateway_route_table(TransitGatewayId=tgw_id)

# Attach VPCs
prod_vpc_attachment = ec2.create_transit_gateway_vpc_attachment(
    TransitGatewayId=tgw_id,
    VpcId='vpc-prod-api',
    SubnetIds=['subnet-prod-a', 'subnet-prod-b', 'subnet-prod-c'],
    Options={
        'DnsSupport': 'enable',
        'Ipv6Support': 'disable',
        'ApplianceModeSupport': 'disable'
    }
)

# Associate attachment with route table (determines which routes it sees)
ec2.associate_transit_gateway_route_table(
    TransitGatewayRouteTableId=prod_rt['TransitGatewayRouteTable']['TransitGatewayRouteTableId'],
    TransitGatewayAttachmentId=prod_vpc_attachment['TransitGatewayVpcAttachment']['TransitGatewayAttachmentId']
)

# Propagate routes: shared services VPC routes appear in prod route table
ec2.enable_transit_gateway_route_table_propagation(
    TransitGatewayRouteTableId=prod_rt['TransitGatewayRouteTable']['TransitGatewayRouteTableId'],
    TransitGatewayAttachmentId='tgw-attach-shared-services'  # Shared VPC's attachment
)

# Static route: Send 0.0.0.0/0 to inspection VPC (for IDS/firewall)
ec2.create_transit_gateway_route(
    TransitGatewayRouteTableId=prod_rt['TransitGatewayRouteTable']['TransitGatewayRouteTableId'],
    DestinationCidrBlock='0.0.0.0/0',
    TransitGatewayAttachmentId='tgw-attach-inspection-vpc'
)
import software.amazon.awssdk.services.ec2.Ec2Client;
import software.amazon.awssdk.services.ec2.model.*;
import java.util.List;

public class TransitGatewaySetup {

    private final Ec2Client ec2 = Ec2Client.create();

    public record TgwInfra(String tgwId, String prodRtId, String devRtId, String sharedRtId) {}

    public TgwInfra createHubAndSpoke() {
        // Create TGW
        CreateTransitGatewayResponse tgwResponse = ec2.createTransitGateway(
            CreateTransitGatewayRequest.builder()
                .description("Central network hub")
                .options(TransitGatewayRequestOptions.builder()
                    .amazonSideAsn(64512L)
                    .autoAcceptSharedAttachments(AutoAcceptSharedAttachmentsValue.DISABLE)
                    .defaultRouteTableAssociation(DefaultRouteTableAssociationValue.DISABLE)
                    .defaultRouteTablePropagation(DefaultRouteTablePropagationValue.DISABLE)
                    .dnsSupport(DnsSupportValue.ENABLE)
                    .build())
                .build());

        String tgwId = tgwResponse.transitGateway().transitGatewayId();

        // Create segmented route tables
        String prodRtId = createRouteTable(tgwId, "prod-routes");
        String devRtId = createRouteTable(tgwId, "dev-routes");
        String sharedRtId = createRouteTable(tgwId, "shared-routes");

        return new TgwInfra(tgwId, prodRtId, devRtId, sharedRtId);
    }

    private String createRouteTable(String tgwId, String name) {
        CreateTransitGatewayRouteTableResponse response = ec2.createTransitGatewayRouteTable(
            CreateTransitGatewayRouteTableRequest.builder()
                .transitGatewayId(tgwId)
                .tagSpecifications(TagSpecification.builder()
                    .resourceType(ResourceType.TRANSIT_GATEWAY_ROUTE_TABLE)
                    .tags(Tag.builder().key("Name").value(name).build())
                    .build())
                .build());
        return response.transitGatewayRouteTable().transitGatewayRouteTableId();
    }
}

PrivateLink creates a one-way channel: a service provider exposes an endpoint, consumers access it through an ENI in their VPC. No route table changes, no CIDR coordination, no transitive routing risk.

# Provider side: Expose a service behind an NLB via PrivateLink

# Step 1: Service is behind a Network Load Balancer
# (Must be NLB, not ALB — PrivateLink only works with NLB or GWLB)

# Step 2: Create VPC Endpoint Service
endpoint_service = ec2.create_vpc_endpoint_service_configuration(
    NetworkLoadBalancerArns=['arn:aws:elasticloadbalancing:us-east-1:111111111111:loadbalancer/net/my-service-nlb/abc123'],
    AcceptanceRequired=True,  # Manually approve connection requests
    TagSpecifications=[{
        'ResourceType': 'vpc-endpoint-service',
        'Tags': [{'Key': 'Name', 'Value': 'payment-service-endpoint'}]
    }]
)
service_id = endpoint_service['ServiceConfiguration']['ServiceId']
service_name = endpoint_service['ServiceConfiguration']['ServiceName']
# Service name looks like: com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0

# Step 3: Allow specific accounts to create endpoints to this service
ec2.modify_vpc_endpoint_service_permissions(
    ServiceId=service_id,
    AddAllowedPrincipals=[
        'arn:aws:iam::222222222222:root',  # Consumer account
        'arn:aws:iam::333333333333:root'
    ]
)

# Consumer side: Create endpoint to the service
consumer_endpoint = ec2.create_vpc_endpoint(
    VpcId='vpc-consumer',
    ServiceName=service_name,
    VpcEndpointType='Interface',
    SubnetIds=['subnet-consumer-a', 'subnet-consumer-b'],
    SecurityGroupIds=['sg-endpoint-access'],
    PrivateDnsEnabled=False  # Usually False for custom services
)
# Consumer now accesses the service via the endpoint DNS:
# vpce-0123456789abcdef0-abc123.vpce-svc-xyz.us-east-1.vpce.amazonaws.com
// Consumer side: Calling a service through PrivateLink
// The DNS endpoint resolves to a private IP in the consumer's VPC
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;

public class PrivateLinkConsumer {

    private static final String ENDPOINT_DNS =
        "vpce-0123456789abcdef0-abc123.vpce-svc-xyz.us-east-1.vpce.amazonaws.com";

    private final HttpClient httpClient = HttpClient.newBuilder()
        .connectTimeout(java.time.Duration.ofSeconds(5))
        .build();

    public String callPaymentService(String orderId, String amount) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://" + ENDPOINT_DNS + "/api/v1/charge"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString("""
                {"order_id": "%s", "amount": "%s"}
                """.formatted(orderId, amount)))
            .build();

        HttpResponse<String> response = httpClient.send(request,
            HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() != 200) {
            throw new PaymentException("Payment failed: " + response.body());
        }
        return response.body();
    }
}

When to Use Which

VPC Peering: Two VPCs that need direct, low-latency, high-bandwidth connectivity. Both sides see each other’s CIDR. No transitive routing (A peers with B, B peers with C, but A cannot reach C through B).

Transit Gateway: You have 5+ VPCs that need some-to-some connectivity with centralized routing control. You want network segmentation (prod can’t reach dev). You need inspection (route all traffic through a firewall VPC).

PrivateLink: You want to expose a specific service (not the whole VPC) to consumers. Consumer doesn’t need to know your CIDR. Works across accounts without any VPC peering or routing changes.

Anti-pattern: Using Transit Gateway when you only have 2 VPCs that need to talk. The $0.05/hour per attachment plus $0.02/GB data processing cost is overkill compared to free VPC peering with $0.01/GB cross-AZ only.