September 14, 2021
Multi-VPC, Single Internet Egress and a Transit Gateway
Focusing on scalability and security as you move to an AWS cloud.
Arun Daniel
When you first get into cloud technologies, you begin with one account and create boundaries in that account. This works for a while and then, sure enough, the floodgates open at some point and you are faced with the Wild West of cloud. As your organization grows its footprint in AWS, you end up relying on hundreds of accounts and Virtual Private Clouds (VPCs) to segment the workloads and/or create segregation within its business divisions. Creating scalable and secure networking architectures lets you further increase that footprint with more governance compared to the Wild West most organizations find themselves in as they mature in the cloud.
Discover how CDW services and solutions can help you with AWS.
Maintaining Security with Scalability
This article touches the tip of the iceberg when it comes to creating multiple VPCs and using one of those VPCs as your egress (and ingress) to (and from) the internet. In the following example, I will walk through having two consumer VPCs that are connected (separately) to an egress VPC, which will be used to send/receive internet traffic.
Although this is a very basic example, you can use your imagination to think about how this could be a stepping stone towards a more secure option by using the egress VPC to hold firewall appliances, logging for security, gateway load balancers (more on this in another post), as well as a slew of other AWS services that can be used to converge a sprawling cloud campus down to a more manageable security post to allow/deny/log all internet traffic.
Managing in AWS Cloud
Here are the ingredients we need to make this hub-and-spoke architecture work:
VPCs
Outbound VPC:
This will be our bouncer for access into Club Internet.
Consumer VPC1 and Consumer VPC2:
These will be two of many VPCs that can hold private AWS services, which will use the Outbound VPC to get out to the internet.
Subnets (and Route Tables for These Subnets)
The outbound VPC will have a public and a private subnet (in production, always using more than one for everything to obtain resiliency). The public subnet will hold the NAT Gateway (again, always have more than one), while the private subnet will hold the Transit Gateway attachment, which will be used to send/receive traffic to/from the internet to the consumer VPCs (also via their own attachments to the Transit Gateway).
Each Consumer VPC will have a private subnet each (in production environments, always have multiple subnets for resiliency).
Transit Gateway
This will be used to connect all the VPCs (specifically, the subnet(s) in the VPCs) with their respective attachments. This object will also contain its own routing tables to segregate the traffic to the internet, traffic to/from the consumer VPCs, and to create blackholes between the consumer VPCs so as not to create any “bridge” between the two (for security purposes).
Internet Gateway
This is our door to the internet and will be connected only to the Outbound VPC
NAT Gateway
Although this object will sit in the Public Subnet in our Outbound VPC, we will use this as our door to the internet for all Consumer VPC traffic destined to the Internet.
Here is what the final logical view will look like:
Hub-and-Spoke Architecture Set-Up
The Breakdown (in Terraform)
Outbound-VPC
#EGRESS/OUTBOUND VPC
resource "aws_vpc" "vpcoutbound" {
cidr_block = "10.10.0.0/16"
tags = {
Name = "vpc-outbound"
}
}
#GATEWAYS
#elastic IP for NAT Gateway resource
resource "aws_eip" "nat" {
vpc = true
tags = {
Name = "vpc-outbound-nat" }
}
#NAT Gateway object and attachment of the Elastic IP Address from above
resource "aws_nat_gateway" "ngw" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.vpcoutboundpubsub1.id
depends_on = [aws_internet_gateway.igw]
tags = {
Name = "ngw-outbound"
}
}
#Internet Gateway
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.vpcoutbound.id
tags = {
Name = "igw-outbound"
}
}
#SUBNETS
#Public Subnet 1
resource "aws_subnet" "vpcoutboundpubsub1" {
cidr_block = "10.10.0.0/24"
vpc_id = aws_vpc.vpcoutbound.id
map_public_ip_on_launch = true
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "vpcoutboundpubsub1"
}
}
#Public Route Table Entry - Internet Bound
resource "aws_route_table" "vpcoutboundroutetablepublic" {
vpc_id = aws_vpc.vpcoutbound.id
route { #send all traffic to the Internet out through the Internet Gateway
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
route { #send all VPC1 Consumer traffic through the Transit Gateway
cidr_block = aws_vpc.vpc1consumer.cidr_block
gateway_id = aws_ec2_transit_gateway.tgw.id
}
route { #send all VPC2 Consumer traffic through the Transit Gateway
cidr_block = aws_vpc.vpc2consumer.cidr_block
gateway_id = aws_ec2_transit_gateway.tgw.id
}
}
#Associate Public Route Table to Public Subnet
resource "aws_route_table_association" "vpcoutboundroutetablepublicas1" {
subnet_id = aws_subnet.vpcoutboundpubsub1.id
route_table_id = aws_route_table.vpcoutboundroutetablepublic.id
}
#Private Subnet 1
resource "aws_subnet" "vpcoutboundprisub1" {
cidr_block = "10.10.2.0/24"
vpc_id = aws_vpc.vpcoutbound.id
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "vpcoutboundprisub1"
}
}
#Private Route Table
resource "aws_route_table" "vpcoutboundroutetableprivate" {
vpc_id = aws_vpc.vpcoutbound.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_nat_gateway.ngw.id
}
tags = {
Name = "vpcoutboundroutetableprivate"
}
}
#Associate Private Route Table to Private Subnet
resource "aws_route_table_association" "vpcoutboundroutetableprivateas1" {
subnet_id = aws_subnet.vpcoutboundprisub1.id
route_table_id = aws_route_table.vpcoutboundroutetableprivate.id
}
#TRANSIT GATEWAYS
resource "aws_ec2_transit_gateway" "tgw" {
default_route_table_association = "disable"
#for security reasons, we dont want to have attached VPCs to use the default route table
default_route_table_propagation = "disable"
#for security reasons, we dont want to have attached VPCs to propogate their networks to the route tables
auto_accept_shared_attachments = "enable"
tags = {
Name = "tgw"
}
}
#outbound vpc attachment
resource "aws_ec2_transit_gateway_vpc_attachment" "outboundvpcattachment" {
subnet_ids = [aws_subnet.vpcoutboundprisub1.id,
aws_subnet.vpcoutboundprisub2.id]
transit_gateway_id = aws_ec2_transit_gateway.tgw.id
vpc_id = aws_vpc.vpcoutbound.id
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
tags = {
Name = "OutboundAttachment"
}
}
#tgw outbound route table
resource "aws_ec2_transit_gateway_route_table" "egressroutetable" {
transit_gateway_id = aws_ec2_transit_gateway.tgw.id
tags = {
Name = "OutboundRouteTable"
}
}
#tgw outbound route table association
resource "aws_ec2_transit_gateway_route_table_association" "outboundvpcassociation" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.outboundvpcattachment.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.egressroutetable.id
}
#route to the consumer 1 via vpc1 attachment
resource "aws_ec2_transit_gateway_route" "egressroutetableRouteVPC1" {
destination_cidr_block = aws_vpc.vpc1consumer.cidr_block
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.vpc1consumervpcattachment.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.egressroutetable.id
}
#route to the consumer 2 via vpc2 attachment
resource "aws_ec2_transit_gateway_route" "egressroutetableRouteVPC2" {
destination_cidr_block = aws_vpc.vpc2consumer.cidr_block
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.vpc2consumervpcattachment.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.egressroutetable.id
}
Consumer-VPC1
#CONSUMER VPC1
resource "aws_vpc" "vpc1consumer" {
cidr_block = "172.16.0.0/16"
tags = {
Name = "vpc1-consumer"
}
}
#Private Subnet 1
resource "aws_subnet" "vpc1consumerprisub1" {
cidr_block = "172.16.1.0/24"
vpc_id = aws_vpc.vpc1consumer.id
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "vpc1consumerprisub1"
}
}
#Private Route Table
resource "aws_route_table" "vpc1consumerroutetableprivate" {
vpc_id = aws_vpc.vpc1consumer.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_ec2_transit_gateway.tgw.id
}
tags = {
Name = "vpc1consumerroutetableprivate"
}
}
#Associate Private Route Table to Private Subnet
resource "aws_route_table_association" "vpcoutboundroutetableprivateVPC1" {
subnet_id = aws_subnet.vpc1consumerprisub1.id
route_table_id = aws_route_table.vpc1consumerroutetableprivate.id
}
#Create TGW Attachment for this VPC
resource "aws_ec2_transit_gateway_vpc_attachment" "vpc1consumervpcattachment" {
subnet_ids = [aws_subnet.vpc1consumerprisub1.id]
transit_gateway_id = aws_ec2_transit_gateway.tgw.id
vpc_id = aws_vpc.vpc1consumer.id
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
tags = {
Name = "VPC1Attachment"
}
}
#tgw vpc 1 consumer route table association
resource "aws_ec2_transit_gateway_route_table_association" "vpc1association" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.vpc1consumervpcattachment.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.consumerroutetable.id
}
#tgw consumer route table
resource "aws_ec2_transit_gateway_route_table" "consumerroutetable" {
transit_gateway_id = aws_ec2_transit_gateway.tgw.id
tags = {
Name = "ConsumerRouteTable"
}
}
#route to the internet via outbound vpc attachment
resource "aws_ec2_transit_gateway_route" "consumerroutetableRouteInternet" {
destination_cidr_block = "0.0.0.0/0"
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.outboundvpcattachment.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.consumerroutetable.id
}
resource "aws_ec2_transit_gateway_route" "blackholevpc1" {
destination_cidr_block = aws_vpc.vpc1consumer.cidr_block
blackhole = true
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.consumerroutetable.id
}
resource "aws_ec2_transit_gateway_route" "blackholevpc2" {
destination_cidr_block = aws_vpc.vpc2consumer.cidr_block
blackhole = true
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.consumerrou
tetable.id
Consumer-VPC2
#CONSUMER VPC2
resource "aws_vpc" "vpc2consumer" {
cidr_block = "172.17.0.0/16"
tags = {
Name = "vpc2-consumer"
}
}
#Private Subnet 1
resource "aws_subnet" "vpc2consumerprisub1" {
cidr_block = "172.17.1.0/24"
vpc_id = aws_vpc.vpc2consumer.id
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "vpc2consumerprisub1"
}
}
#Private Route Table
resource "aws_route_table" "vpc2consumerroutetableprivate" {
vpc_id = aws_vpc.vpc2consumer.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_ec2_transit_gateway.tgw.id
}
tags = {
Name = "vpc2consumerroutetableprivate"
}
}
#Associate Private Route Table to Private Subnet
resource "aws_route_table_association" "vpcoutboundroutetableprivateVPC2" {
subnet_id = aws_subnet.vpc2consumerprisub1.id
route_table_id = aws_route_table.vpc2consumerroutetableprivate.id
}
resource "aws_ec2_transit_gateway_vpc_attachment" "vpc2consumervpcattachment" {
subnet_ids = [aws_subnet.vpc2consumerprisub1.id]
transit_gateway_id = aws_ec2_transit_gateway.tgw.id
vpc_id = aws_vpc.vpc2consumer.id
transit_gateway_default_route_table_association = false
transit_gateway_default_route_table_propagation = false
tags = {
Name = "VPC2Attachment"
}
}
resource "aws_ec2_transit_gateway_route_table_association" "vpc2association" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.vpc2consumervpcattachment.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.consumerroutetable.id
}
Testing Your Architecture
To test the above hub-and-spoke architecture, you can:
Create a jump host in the Outbound VPC in the public subnet and confirm that the EC2 instance will have a public IP address along with a security group to allow you to SSH or RDP to this jump box.
Create a private instance in the Consumer VPC 1 network and allow SSH or RDP access to this instance from the jump box.
Create another private instance in the Consumer VPC 2 network and allow SSH or RDP access to this instance from the jump box.
Connect to the jump box and then connect to each of the Consumer EC2 instances.
You should be able to confirm that from either Consumer EC2 instance you can connect to the Internet; however, you cannot connect to the other Consumer EC2 instance (thanks to the blackhole route).
Network Flow
To conclude, all Consumer VPC internet traffic is routed through the Transit Gateway, through the NAT Gateway, out to the internet. Thanks to the blackhole routing entries, the traffic between the two Consumer VPCs will never be allowed.
Arun Daniel is a Sr. Consulting Engineer for Data Center and Cloud Services at CDW with more than 20 years of experience designing, deploying and managing all aspects of data center and cloud services. For the past 10 years, his primary focus has been migrations from on-premises data centers to Amazon Web Services. Arun holds numerous AWS certifications, as well as HashiCorp, Microsoft Azure, VMware and Cisco certifications.