Building out a simple AWS VPC
The AWS Console is really handy to get something up and running in the prototyping stage of a project. But often times the temptation is there to just take the 'ok, thats working now, phew!' approach and move on. Inevitably, 6 months or more down the track you've got to either recreate a similar resource or redeploy the same resource again, and despite being sure you'd remember all the little aspects, you've got to go on the voyage of rediscovery all over again to re-identify all the little settings you need.
Because I have the memory of a goldfish, I prefer to take a 'Infrastructure as Code' approach, even for personal side projects. So, here's a walk through how to write a Cloudformation template to provision a VPC with an Internet Gateway, three public subnets, three private subnets across three Availability Zones, a couple of interface VPC endpoints for access to Secrets Manager and SNS and a number of Cloudformation exports that can be used when deploying other resources within the VPC to allow easy access to subnet Ids and such like.
tldr; - if you're just after the template, you can grab it from here
So, first things first, we want to create the VPC itself, and add three public subnets. This template does assume that the region that it will be deployed into does in fact have three Availability Zones. If that assumption doesn't hold in the region of choice or the preference is to not deploy three public subnets, then reduce appropriately.
On the off chance the script might be used to duplicate an identical VPC and subnets with a different IP range and name, then parameters help modify these settings.
AWS recommend using private IP address ranges recommended in RFC1918 for the private IP address space for your VPC. Basically one of these;
10.0.0.0 - 10.255.255.255 (10/8 prefix)
172.16.0.0 - 172.31.255.255 (172.16/12 prefix)
192.168.0.0 - 192.168.255.255 (192.168/16 prefix)
The script below takes the two parameters and creates the VPC. Then three public subnets (note the MapPublicIpOnLaunch set to True) are created, arbitrarily selecting .1.0/24, .2.0/24 and .3.0/24 as the CIDR blocks for these and attaching/associating them with the VPC.
Assuming the default of 192.168 as the VPCOctet and also assuming deployment in the ap-southeast-2 region, this will result in the following subnets;
Subnet Name | Start IP | End IP | IPs in range | Availability Zone (AZ) |
---|---|---|---|---|
my-aws-vpc-pub-a | 192.168.1.0 | 192.168.1.255 | 256 | ap-southeast-2a |
my-aws-vpc-pub-b | 192.168.2.0 | 192.168.2.255 | 256 | ap-southeast-2b |
my-aws-vpc-pub-c | 192.168.3.0 | 192.168.3.255 | 256 | ap-southeast-2c |
If more IP addresses are needed in each subnet, adjust the CidrBlock for each subnet. Be sure not to overlap any. For now, this set up will suit our simple use case. Be sure to plan ahead however - if there's any possibility that more IPs will be needed, adjust now otherwise trying to reconfigure the VPC later will just result in tears. Making these subnets /24 subnets means we will have enough space for 256 /24 subnets in the /16 space. If you're wanting to increase the pool of private IPs in each subnet to give additional capacity, make the subnets /20 subnets. That will reduce the total number of possible subnets to 16 but the address space for a /20 subnet is 4096 addresses.
Also its worth noting that AWS reserves 5 addresses in each VPC so not all 256 addresses are available. For example, in the my-aws-vpc-pub-a, the following addresses are reserved and cannot be used;
- 192.168.1.0
- 192.168.1.1
- 192.168.1.2
- 192.168.1.3
- 192.168.1.255
AWSTemplateFormatVersion: '2010-09-09'
Description: Creates AWS infrastructure for the primary (non default) VPC
Parameters:
VPCOctet:
Description: First two octets of the VPC (e.g. '192.168' for '192.168.0.0/16')
Type: String
MinLength: 4
MaxLength: 7
AllowedPattern: "[0-9]{2,3}.[0-9]{1,3}"
ConstraintDescription: Must only be the first two octets without a trailing period
Default: '192.168'
VPCName:
Description: The name for the VPC
Type: String
MinLength: 3
MaxLength: 255
Default: 'my-aws-vpc'
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
CidrBlock: !Join [".", [!Ref VPCOctet, '0.0/16'] ]
InstanceTenancy: default
Tags:
- Key: Name
Value: !Ref VPCName
- Key: VpcOctet
Value: !Ref VPCOctet
PublicSubnetA:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Join ["", [!Ref 'AWS::Region', a]]
CidrBlock: !Join [".", [!Ref VPCOctet, '1.0/24'] ]
MapPublicIpOnLaunch: true
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, pub , a] ]
VpcId: !Ref VPC
PublicSubnetB:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Join ["", [!Ref 'AWS::Region', b]]
CidrBlock: !Join [".", [!Ref VPCOctet, '2.0/24'] ]
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, pub ,b] ]
VpcId: !Ref VPC
PublicSubnetC:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Join ["", [!Ref 'AWS::Region', c]]
CidrBlock: !Join [".", [!Ref VPCOctet, '3.0/24'] ]
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, pub , c] ]
VpcId: !Ref VPC
Next to add are three corresponding private subnets. The private subnets are for resources that we don't want to allow external access to - an RDS database for instance. Add the following to the script above to create the private subnets to 'match' the public subnets. The main thing to note is these have their MapPublicIpOnLaunch set to false so that new resources added to these subnets do not by default get assigned a public IP address.
PrivateSubnetA:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Join ["", [!Ref 'AWS::Region', a]]
CidrBlock: !Join [".", [!Ref VPCOctet, '5.0/24'] ]
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, private, a] ]
VpcId: !Ref VPC
PrivateSubnetB:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Join ["", [!Ref 'AWS::Region', b]]
CidrBlock: !Join [".", [!Ref VPCOctet, '6.0/24'] ]
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, private, b] ]
VpcId: !Ref VPC
PrivateSubnetC:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone: !Join ["", [!Ref 'AWS::Region', c]]
CidrBlock: !Join [".", [!Ref VPCOctet, '7.0/24'] ]
MapPublicIpOnLaunch: false
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, private, c] ]
VpcId: !Ref VPC
This now gives the following subnets
Subnet Name | Start IP | End IP | IPs in range | Availability Zone (AZ) |
---|---|---|---|---|
my-aws-vpc-pub-a | 192.168.1.0 | 192.168.1.255 | 256 | ap-southeast-2a |
my-aws-vpc-pub-b | 192.168.2.0 | 192.168.2.255 | 256 | ap-southeast-2b |
my-aws-vpc-pub-c | 192.168.3.0 | 192.168.3.255 | 256 | ap-southeast-2c |
my-aws-vpc-private-a | 192.168.5.0 | 192.168.5.255 | 256 | ap-southeast-2a |
my-aws-vpc-private-b | 192.168.6.0 | 192.168.6.255 | 256 | ap-southeast-2b |
my-aws-vpc-private-c | 192.168.7.0 | 192.168.7.255 | 256 | ap-southeast-2c |
Nothing created in any of these subnets currently will have access out to the internet. To do so, an Internet Gateway needs to be created and associated with the VPC.
Add the following to the template file to create the InternetGateway and attach it to the VPC
IGW:
Type: AWS::EC2::InternetGateway
Properties:
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, igw] ]
AttachIGWtoVPC:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref VPC
InternetGatewayId: !Ref IGW
Next, add a route table to the VPC. A VPC will have a default route table already that will already be associated with the three public and three private subnets. The only entry in this default route table will to route all traffic for the VPC address space locally. In order for traffic to be able to go via the InternetGateway out to the internet a route needs to be added from the public subnets to the Internet Gateway (IGW). Whilst it would be 'simpler' to add the route to the IGW to the default route table, that will result in the private subnets also having a route to/from the internet - something we do not want. So, a new route table should be created and that route table can have the route added to the IGW and that route table can then be associated solely with the public subnets.
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, public, rt] ]
PublicRouteTableIGWRoute:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PublicRouteTable
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref IGW
RouteTableAssPubA:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnetA
RouteTableAssPubB:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnetB
RouteTableAssPubC:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PublicRouteTable
SubnetId: !Ref PublicSubnetC
The VPC resources now look like this. Note that by explicitly associating the public subnets with the new 'public' route table, they have been disassociated from the default VPC route table. Now only the private subnets are associated with this route table. Also note that even though a route was not explicitly added for 'local' traffic (within the VPC address space) one will be added to the route table.
While not strictly necessary, for consistency and to keep things tidy, a route table could also be created and associated with the private subnets. THis will disassociate the private subnets from the default VPC route table and make it slightly clearer.
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref VPC
Tags:
- Key: Name
Value: !Join ["-", [!Ref VPCName, private, rt] ]
RouteTableAssPrivateA:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTable
SubnetId: !Ref PrivateSubnetA
RouteTableAssPrivateB:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTable
SubnetId: !Ref PrivateSubnetB
RouteTableAssPrivateC:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTable
SubnetId: !Ref PrivateSubnetC
Next step is to add two interface VPC endpoints to the VPC to allow access to Secrets Manager and SNS from within the private subnets. I'm doing this because I know I will eventually be deploying AWS Lambda functions within the private subnets as they will require access to an upcoming AWS RDS instance that will be deployed into a private subnet as well as still having access to SecretsManager and SNS.
BE AWARE that interface VPC endpoints cost. Approx. $20 per month. Each. So only add these if you KNOW you are going to use them. Once you get more than 1 or two interface VPC endpoints, there are other solutions (that also cost) but for the time being I'm sticking with these two endpoints.
SecretsManagerInterfaceEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
PrivateDnsEnabled: true
VpcEndpointType: 'Interface'
ServiceName: !Sub com.amazonaws.${AWS::Region}.secretsmanager
VpcId: !Ref VPC
SubnetIds:
- !Ref PrivateSubnetA
- !Ref PrivateSubnetB
- !Ref PrivateSubnetC
SecurityGroupIds:
- sg-0cxxxxxxxxxxxxxxx #change this
- sg-099a6e0df342beb04
SnsInterfaceEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
PrivateDnsEnabled: true
VpcEndpointType: 'Interface'
ServiceName: !Sub com.amazonaws.${AWS::Region}.sns
VpcId: !Ref VPC
SubnetIds:
- !Ref PrivateSubnetA
- !Ref PrivateSubnetB
- !Ref PrivateSubnetC
SecurityGroupIds:
- sg-0cb8b3b30154b4bf2
- sg-099a6e0df342beb04
Finally, some of the resources created in this template/stack will be useful for importing (via !ImportValue) into other Cloudformation templates. This then avoids the need to hard code these Ids elsewhere.
Outputs:
VPCID:
Value: !Ref VPC
Description: ID of the VPC deployed
Export:
Name: !Join ["-", [vpc, id]]
VPCCidrBlock:
Value: !GetAtt VPC.CidrBlock
Description: ID of the VPC deployed
Export:
Name: !Join ["-", [vpc, cidr]]
PublicSubnetA:
Value: !Ref PublicSubnetA
Description: ID of the public subnet
Export:
Name: subnet-pub-a
PublicSubnetB:
Value: !Ref PublicSubnetB
Description: ID of the public subnet
Export:
Name: subnet-pub-b
PublicSubnetC:
Value: !Ref PublicSubnetC
Description: ID of the public subnet
Export:
Name: subnet-pub-c
PrivateSubnetA:
Value: !Ref PrivateSubnetA
Description: ID of the private subnet
Export:
Name: subnet-private-a
PrivateSubnetB:
Value: !Ref PrivateSubnetB
Description: ID of the private subnet
Export:
Name: subnet-private-b
PrivateSubnetC:
Value: !Ref PrivateSubnetC
Description: ID of the private subnet
Export:
Name: subnet-private-c
The end result is a relatively simple VPC. There's no Network ACLs added (these certainly can be included) NAT gateways, transit gateways or VPC peering connections - all of which also could be added but aren't necessarily needed for a simple VPC.