Simplifying Multi-VPC Developer Access with Client VPN and Transit Gateway
Posted April 21, 2025 by Trevor Roberts Jr ‐ 5 min read
A network administrator's life might be simpler if all the development environments were in the same VPC. However, blast radius concerns are a thing 😅. Let's see how to enable developers to access resoures in other VPCs with the Transit Gateway and with Client VPN...
Introduction
This blog post is the second in a three-part series covering Client VPN configurations. Click on part one or part three (multi-region) for additional content
AWS Client VPN is great for allowing secure access to instances and other resources within your VPC. What if your developers need access to resources in another VPC?
Fortunately, AWS has multiple options to connect multiple VPCs together: VPC Peering and Transit Gateway. VPC Peering is AWS's original solution for connecting VPCs, but it requires some additional administrative overhead due to not supporting transitive peering: i.e. if you peer VPC A with VPC B and VPC B with VPC C, VPC A and VPC C cannot communicate with each other unless you also peer VPC A and VPC C.
The Transit Gateway (TGW) launched in 2018 to simplify interconnectivity between VPCs. Instead of managing multiple VPC-to-VPC connections, you create a TGW, attach the VPCs that need to communicate with each other, and configure your routing tables, NACLs, and security groups to manage network traffic between the VPCs based on your requirements.
In this blog post, I will show how to extend the architecture from the previous blog post to connect instances from two VPCs via a TGW as pictured in this diagram:

Let's Connect Those VPCs...powered by Pulumi
The entire Pulumi stack source code can be on GitHub. I will focus on the most interesting portions of the code below.
Looking back at part 1, main.go
was getting a little long at 300+ lines. The first thing I did was modularize my code so that there was a go file for each resource type. See the file system structure that follows:
/Users/me/clientvpn
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── README.md
├── go.mod
├── go.sum
├── main.go
└── utils
├── clientvpn.go
├── ec2.go
├── tgw.go
└── vpc.go
I added the TGW creation and route creation code to the tgw.go
file:
func CreateTransitGateway(ctx *pulumi.Context, name string) (*ec2transitgateway.TransitGateway, error) {
return ec2transitgateway.NewTransitGateway(ctx, name, &ec2transitgateway.TransitGatewayArgs{
AmazonSideAsn: pulumi.Int(64512),
AutoAcceptSharedAttachments: pulumi.String("enable"),
DefaultRouteTableAssociation: pulumi.String("enable"),
DefaultRouteTablePropagation: pulumi.String("enable"),
Tags: pulumi.StringMap{
"Name": pulumi.String(name),
},
})
}
// parameters include the TGW to add VPC routes to and a list of VPCs
func AttachVPCsToTGW(ctx *pulumi.Context, tgw *ec2transitgateway.TransitGateway, vpcs ...*VPCResult) ([]*ec2transitgateway.VpcAttachment, error) {
var attachments []*ec2transitgateway.VpcAttachment
// for each VPC, create a VPC attachment
for i, vpc := range vpcs {
var subnetIds pulumi.StringArray
for _, s := range vpc.PrivateTgwSubnets {
subnetIds = append(subnetIds, s.ID())
}
attachment, err := ec2transitgateway.NewVpcAttachment(ctx, fmt.Sprintf("vpc-tgw-attachment-%d", i), &ec2transitgateway.VpcAttachmentArgs{
VpcId: vpc.Vpc.ID(),
TransitGatewayId: tgw.ID(),
SubnetIds: subnetIds,
Tags: pulumi.StringMap{
"Name": pulumi.String(fmt.Sprintf("attachment-%d", i)),
},
})
if err != nil {
return nil, err
}
attachments = append(attachments, attachment)
}
return attachments, nil
}
By default, all VPCs attached to the TGW have their routes propagated (you can disable this if needed). The remaining network configurations to be handled include:
- Each subnet's route table has a route to the other subnet via the TGW
- The compute resources' security groups allow traffic from each other, as appropriate.
In vpc.go
, I add a function to add my vpc routes to the TGW
func (v *VPCResult) AddTGWRoute(ctx *pulumi.Context, name string, destinationCidr string, tgwId pulumi.IDOutput) error {
for az, subnet := range v.PrivateComputeSubnets {
rt, err := ec2.NewRouteTable(ctx, fmt.Sprintf("%s-tgw-rt-%s", name, az), &ec2.RouteTableArgs{
VpcId: v.Vpc.ID(),
Tags: pulumi.StringMap{
"Name": pulumi.String(fmt.Sprintf("%s-tgw-rt-%s", name, az)),
},
})
if err != nil {
return err
}
_, err = ec2.NewRoute(ctx, fmt.Sprintf("%s-tgw-route-%s", name, az), &ec2.RouteArgs{
RouteTableId: rt.ID(),
DestinationCidrBlock: pulumi.String(destinationCidr),
TransitGatewayId: tgwId,
})
if err != nil {
return err
}
_, err = ec2.NewRouteTableAssociation(ctx, fmt.Sprintf("%s-tgw-rt-assoc-%s", name, az), &ec2.RouteTableAssociationArgs{
SubnetId: subnet.ID(),
RouteTableId: rt.ID(),
})
if err != nil {
return err
}
}
return nil
}
Finally, in main.go
, I call my new functions to incorporate the TGW into my architecture:
// Transit Gateway
tgw, err := utils.CreateTransitGateway(ctx, "tgw")
if err != nil {
return err
}
_, err = utils.AttachVPCsToTGW(ctx, tgw, vpc1, vpc2)
if err != nil {
return err
}
// Add TGW routes
err = vpc1.AddTGWRoute(ctx, "vpc1", vpc2Cidr, tgw.ID())
if err != nil {
return err
}
err = vpc2.AddTGWRoute(ctx, "vpc2", vpc1Cidr, tgw.ID())
if err != nil {
return err
}
Wrapping Things Up...
In this blog post, we discussed how the Transit Gateway (TGW) can simplify connecting multiple VPCs. With just a few additional lines of IaC, your developers can access the resources they are allowed to access across multiple VPCs.
If you found this article useful, let me know on BlueSky or on LinkedIn!