Simplifying Multi-Region Developer Access with Client VPN and Transit Gateway

Posted May 20, 2025 by Trevor Roberts Jr ‐ 4 min read

Sometimes, developers need to access resources in multiple regions. The good news is that the Transit Gateway and Client VPN can help you with this use case too! Read on to see how...

Introduction

This blog post is the last of a three-part series covering Client VPN configurations. Click on part one or part two (intra-region, multi-vpc) for additional content

For this design, I connected my development resources in multiple regions via TGW peering. See the following architecture diagram:

Figure 01: Instance Connectivity via TGW Peering
Figure 01: Instance Connectivity via TGW Peering

Who's on First? - i.e. Know Your Network Configs

With the addition of TGW peering to my network mix, I wanted to pause and share a checklist of network configurations that are necessary for this architecture to work:

  1. Security Groups (SGs) ✔️ Allow inbound traffic from the remote EC2’s VPC CIDR or specific IP. NOTE: I didn't use NACL's in my environment, but if you do, make sure you configure them accordingly.

  2. Subnet Route Tables ✔️ Each EC2’s subnet route table must have a route to the other subnet via its respective TGW.

  3. Transit Gateway Routes (static) ✔️ Each TGW must have a static route to the remote VPC CIDR via the peering attachment.

  4. Local VPC Attachment Routing The TGW route table also needs to have a route back to the local VPC. You can either do this dynamically via route propagation or by defining static routes via the VPC attachment.

🔄 Don’t Forget: I put my thing down, flip, and reverse it.

You must configure the reverse path by making similar configurations in the other region. Otherwise, traffic will flow one way and get dropped on the return. Fortunately, IaC simplifies this task significantly.

In summation

ComponentRequired?Notes
EC2 Security groupsAllow TGW peer VPC CIDRs
Subnet route to peer VPC CIDRvia local TGW
TGW route to peer CIDRvia TGW peering attachment (static only)
TGW route to local VPC CIDRvia VPC attachment (propagation or static)
Route table (RT) associationAssociate VPC and peering attachments to the custom TGW RT
TGW propagation (VPC only)Optional if using static local VPC routes

TGW Peering...powered by Pulumi

The complete source is up on GitHub, and I will focus on interesting code snippets here.

func CreateTGWPeeringAttachmentAndRoutes(
	ctx *pulumi.Context,
	name string,
	localTgw *ec2transitgateway.TransitGateway,
	peerTgw *ec2transitgateway.TransitGateway,
	peerRegion string,
	eastCidr string,
	westCidr string,
	eastTgwRt *ec2transitgateway.RouteTable,
	westTgwRt *ec2transitgateway.RouteTable,
	optsLocal pulumi.ResourceOption,
	optsPeer pulumi.ResourceOption,
) (*ec2transitgateway.PeeringAttachment, *ec2transitgateway.PeeringAttachmentAccepter, error)

Within this function, I am creating resources in two regions. Hence, you see the inclusion of optsLocal and optsPeer parameters that include the Pulumi provider configurations for us-east-2, and us-west-2, respectively.

	// Create the peering attachment in the local region
peering, err := ec2transitgateway.NewPeeringAttachment(ctx, name+peerRegion, &ec2transitgateway.PeeringAttachmentArgs{
	TransitGatewayId:     localTgw.ID(),
	PeerTransitGatewayId: peerTgw.ID(),
	PeerRegion:           pulumi.String(peerRegion),
	Tags: pulumi.StringMap{
		"Name": pulumi.String(name + peerRegion),
	},
}, pulumi.DependsOn([]pulumi.Resource{peerTgw}), optsLocal)
if err != nil {
	return nil, nil, err
}
// Accept the peering attachment in the peer region, with DependsOn
accepter, err := ec2transitgateway.NewPeeringAttachmentAccepter(ctx, name+"-accepter", &ec2transitgateway.PeeringAttachmentAccepterArgs{
	TransitGatewayAttachmentId: peering.ID(),
	Tags: pulumi.StringMap{
		"Name": pulumi.String(name + "-accepter"),
	},
}, pulumi.DependsOn([]pulumi.Resource{peering}), optsPeer)
if err != nil {
	return nil, nil, err
}

The biggest change to the code was automating the TGW peering connection. In this method, I make the peering request (peering) from the us-east-2 TGW, and accept the request (accepter) from the us-west-2 TGW. I include the DependsOn method calls to avoid race conditions due to TGW configurations sometimes taking a minute or more to complete.


	// Associate accepter with custom route table
	_, err = ec2transitgateway.NewRouteTableAssociation(ctx, name+"-accepter-rt-assoc", &ec2transitgateway.RouteTableAssociationArgs{
		TransitGatewayAttachmentId: accepter.ID(),
		TransitGatewayRouteTableId: westTgwRt.ID(),
	}, pulumi.DependsOn([]pulumi.Resource{accepter}), optsPeer)
	if err != nil {
		return nil, nil, err
	}

	// Add route to peer CIDR in local TGW route table via the peering attachment
	_, err = ec2transitgateway.NewRoute(ctx, name+"-local-tgw-route", &ec2transitgateway.RouteArgs{
		TransitGatewayRouteTableId: eastTgwRt.ID(),
		DestinationCidrBlock:       pulumi.String(westCidr),
		TransitGatewayAttachmentId: peering.ID(),
	}, pulumi.DependsOn([]pulumi.Resource{accepter}), optsLocal)
	if err != nil {
		return nil, nil, err
	}

I use custom TGW route tables for more flexibility with the TGW configuration, and you see them used with the ec2transitgateway.NewRouteTableAssociation and ec2transitgateway.NewRoute methods. As of the publication date of this blog post, TGW does not support route propagation for TGW peering connections like it does for VPC attachments. So, I use the ec2transitgateway.NewRoute method to specify which addresses are accessible via each peering attachment.

Wrapping Things Up...

In this blog post, we discussed how Transit Gateway peering enables multi-region connectivity. This gives you flexibility in configuring a single instance of Client VPN to connect your developers to resources in all the regions your team uses. Pretty powerful stuff!

If you found this article useful, let me know on BlueSky or on LinkedIn!