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.

The diagramatic resource map of the VPC from the AWS VPC console showing only private subnets connected to the default VPC route table

The diagramatic resource map of the VPC from the AWS VPC console showing public subnets connected to the 'public' VPC route table and then the internet gateway

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.