Creating an AWS VPC with Terraform
Until recently, my personal projects (including this blog) were hosted in Linode. At this point, private networking is a must-have for me, and Linode does not support it in a way that works well in practice (and this is a common complaint about the platform) - so I decided to create an AWS account and VPC for my stuff. I will outline everything I had to do to get it to work in this post.
Creating the VPC with Terraform
The basic parts of an AWS VPC are:
- The VPC resource itself
- An internet gateway (for connectivity to and from the internet)
- Public and private subnets
- Route tables for the public and private subnets
- A NAT gateway or NAT instance in the public subnets (to enable internet access from the private subnets)
- A private hosted zone in R53 for private DNS within the VPC (optional)
For my VPC, I decided that two private subnets and two public subnets would be enough. It would allow me to deploy to two availability zones, and it would allow me to deploy my apps and databases without worrying about them being directly reachable over the internet.
VPC resource
The first thing that needs to be created is the VPC resource itself.
resource "aws_vpc" "vpc" { cidr_block = var.cidr_blocks.vpc_cidr instance_tenancy = "default" enable_dns_support = true enable_dns_hostnames = true tags = { Name = var.name } }
It's just a few lines of HCL, but there is one very important piece of information here: the VPC CIDR. This determines the range of IP addresses available within the VPC, and the CIDR block specified will need to be divided into smaller CIDR blocks - one for each of the subnets in the VPC (more on that below). For my VPC, I settled on 10.0.0.0/16 for the VPC CIDR, based in part on the recommendations in the answer to this StackOverflow question.
Hosted zone and internet gateway
Next, the private hosted zone and internet gateway need to be created.
resource "aws_internet_gateway" "internet_gateway" { vpc_id = aws_vpc.vpc.id } resource "aws_route53_zone" "private_hosted_zone" { name = var.private_hosted_zone_name vpc { vpc_id = aws_vpc.vpc.id } }
The private hosted zone will allow private DNS resolution within the VPC. That's something I wanted for my VPC, but like I said above - it's optional. The internet gateway is needed to enable internet access to and from the VPC. Later, it will be associated with the public subnets of the VPC via a route in the public subnets' route table.
Public and private subnets
Next, the private and public subnets can be created. Like I said above, the CIDR block specified for the VPC needs to be split into smaller blocks, and each of these smaller blocks will belong to one subnet. I used a subnet calculator to figure this out - you can see how I divided my VPC's CIDR block here. I chose to leave four CIDR blocks unallocated in case I need to add more subnets in the future.
After I figured that out, I decided to put the VPC CIDR block and subnet CIDR blocks into an object in my variables file to make it clear what each block was allocated for.
variable "cidr_blocks" { default = { vpc_cidr = "10.0.0.0/16" public_subnet_1_cidr = "10.0.0.0/19" public_subnet_2_cidr = "10.0.32.0/19" private_subnet_1_cidr = "10.0.64.0/19" private_subnet_2_cidr = "10.0.96.0/19" unallocated_space_1_cidr = "10.0.128.0/19" unallocated_space_2_cidr = "10.0.160.0/19" unallocated_space_3_cidr = "10.0.192.0/19" unallocated_space_4_cidr = "10.0.224.0/19" } type = object( { vpc_cidr = string public_subnet_1_cidr = string public_subnet_2_cidr = string private_subnet_1_cidr = string private_subnet_2_cidr = string unallocated_space_1_cidr = string unallocated_space_2_cidr = string unallocated_space_3_cidr = string unallocated_space_4_cidr = string } ) }
Next, I finally created the four subnets I needed as shown below.
resource "aws_subnet" "public_1" { vpc_id = aws_vpc.vpc.id cidr_block = var.cidr_blocks.public_subnet_1_cidr availability_zone = var.availability_zone_1_name tags = { Name = "${var.name}_public_1" } } resource "aws_subnet" "public_2" { vpc_id = aws_vpc.vpc.id cidr_block = var.cidr_blocks.public_subnet_2_cidr availability_zone = var.availability_zone_2_name tags = { Name = "${var.name}_public_2" } } resource "aws_subnet" "private_1" { vpc_id = aws_vpc.vpc.id cidr_block = var.cidr_blocks.private_subnet_1_cidr availability_zone = var.availability_zone_1_name tags = { Name = "${var.name}_private_1" } } resource "aws_subnet" "private_2" { vpc_id = aws_vpc.vpc.id cidr_block = var.cidr_blocks.private_subnet_2_cidr availability_zone = var.availability_zone_2_name tags = { Name = "${var.name}_private_2" } }
Note that each subnet in each pair of subnets (public/private) is in a different availability zone. This ensures that apps running in the VPC will stay online if one availability zone goes down.
NAT gateway
Next, a NAT gateway or NAT instance needs to be created in one of the public subnets. Without it, the private subnets will not have internet access. That's something I needed, since I wanted to do AMI builds in the private subnets and it wouldn't be possible to install packages without access to the internet.
Most of the time (and especially for "real" AWS accounts hosting applications important to an actual business), a NAT gateway will be the best choice.
resource "aws_eip" "nat_public_ip" { vpc = true } resource "aws_nat_gateway" "nat_gateway" { allocation_id = aws_eip.nat_public_ip.id subnet_id = aws_subnet.public_2.id tags = { Name = "${var.name}_nat_gateway" } }
However, if you are an individual setting up a VPC for personal use and you happen to be as cheap as I am, you can use a NAT instance instead of a NAT gateway. I recently wrote a separate post explaining how to create a NAT instance and why you might want to, so I won't go into it here.
Route tables
Finally, the route tables can be created. The public route table has a route that sends outbound traffic from the public subnets to the internet gateway, and the private route table has a route that sends outbound traffic from the private subnets to the NAT gateway.
resource "aws_route_table" "public_route_table" { vpc_id = aws_vpc.vpc.id route = [ { cidr_block = "0.0.0.0/0", gateway_id = aws_internet_gateway.internet_gateway.id } ] } resource "aws_route_table_association" "public_route_table_association_1" { subnet_id = aws_subnet.public_1.id route_table_id = aws_route_table.public_route_table.id } resource "aws_route_table_association" "public_route_table_association_2" { subnet_id = aws_subnet.public_2.id route_table_id = aws_route_table.public_route_table.id } resource "aws_route_table" "private_route_table" { vpc_id = aws_vpc.vpc.id route = [ { cidr_block = "0.0.0.0/0", nat_gateway_id = aws_nat_gateway.nat_gateway.id } ] } resource "aws_route_table_association" "private_route_table_association_1" { subnet_id = aws_subnet.private_1.id route_table_id = aws_route_table.private_route_table.id } resource "aws_route_table_association" "private_route_table_association_2" { subnet_id = aws_subnet.private_2.id route_table_id = aws_route_table.private_route_table.id }
This is the last step - after this is done, the public subnets will be reachable over the internet and the private subnets will have internet access without being directly reachable over the internet themselves. At this point, the VPC is set up and ready to be used.