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:

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:
-
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.
-
Subnet Route Tables ✔️ Each EC2’s subnet route table must have a route to the other subnet via its respective TGW.
-
Transit Gateway Routes (static) ✔️ Each TGW must have a static route to the remote VPC CIDR via the peering attachment.
-
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
Component | Required? | Notes |
---|---|---|
EC2 Security groups | ✅ | Allow TGW peer VPC CIDRs |
Subnet route to peer VPC CIDR | ✅ | via local TGW |
TGW route to peer CIDR | ✅ | via TGW peering attachment (static only) |
TGW route to local VPC CIDR | ✅ | via VPC attachment (propagation or static) |
Route table (RT) association | ✅ | Associate 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!