Terraforming an AWS VPC — Part 3
This is the third part of a series of articles on how to set up an AWS VPC using Terraform version 0.12.29.
We have created our first Terraform project in Part 1 and had our introduction to remote state files and workspaces in Part 2. In this post, we will finish creating our VPC by adding these resources:
- AWS VPC (done)
- Internet Gateway
- 3x Public subnet — one for each AZ
- 3x Private subnet — one for each AZ
- 3x Database subnet — one for each AZ
- Public subnet route table
- Private subnet route table
- Database subnet route table
- EC2 Bastion Host
- Elastic IP Address
- NAT Gateway
ℹ️ The instructions here is the hard way of doing it! We will add each AWS resource one by one to get you acquainted with Terraform. An easier way to create a VPC is using a third party Terraform AWS VPC module which we will cover in the next and final post of the series.
VPC Diagram
The diagram below is to help you visualize what we will create.
Provisioning AWS Resources
❌ The AWS on-demand resources we will be provisioning in this section will incur cost. Make sure you terminate all resources when you are done. You can do it manually or by running
terraform destroy
Updating the VPC
Switch to the DEV
workspace.
$ terraform workspace select dev
$ terraform workspace list
Open main.tf
, this is where we will add all the VPC Terraform resources. After each section, we will run terraform apply
to do incremental updates to our VPC.
#-------------------------------
# Internet Gateway
#-------------------------------
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.idtags = {
Name = "my-internet-gateway-${terraform.workspace}"
}
}
This will open communication from our VPC to the internet. Apply the change.
$ terraform apply -var-file=env/dev/vpc.tfvars
We will create three subnets (public, private, database subnet) for each Availability Zones. There are 3 AZs for the Sydney Region defined in our DEV
workspace. Giving us a total of 9 subnets.
Terraform uses input variables to make the script more flexible and sharable. We will create the variables and set the values in the DEV
workspace. This will allow us to re-use the Terraform scripts to provision multiple environments.
Open variables.tf
and add the map variable declarations below. These will be set to the AZ values distinct to each workspace. Remember that in Part 1 we set each environment to use different AWS regions.
#-------------------------------
# Variables
#-------------------------------
variable "az_public_subnet" {
type = map(string)
}variable "az_private_subnet" {
type = map(string)
}variable "az_database_subnet" {
type = map(string)
}
Now the customization part, open the file env/dev/vpc.tfvars
and set the key-value pairs for the DEV
workspace. These are the CIDR blocks assigned to each subnet as shown in the diagram above.
az_public_subnet = { "ap-southeast-2a" : "10.0.0.0/24", "ap-southeast-2b" : "10.0.1.0/24", "ap-southeast-2c" : "10.0.2.0/24" }az_private_subnet = { "ap-southeast-2a" : "10.0.101.0/24", "ap-southeast-2b" : "10.0.102.0/24", "ap-southeast-2c" : "10.0.103.0/24" }az_database_subnet = { "ap-southeast-2a" : "10.0.201.0/24", "ap-southeast-2b" : "10.0.202.0/24", "ap-southeast-2c" : "10.0.203.0/24" }
Re-open main.tf
and add the Terraform subnet resources. We are using the resource meta-argument for-each. It will provision an AWS subnet resource multiplied by the number of items in the map variables var.az_public_subnet
, var.az_private_subnet
, and var.az_database_subnet
, creating a total of 9 subnets.
# Public Subnet
resource "aws_subnet" "public_subnet" {
for_each = var.az_public_subnetvpc_id = aws_vpc.main.idavailability_zone = each.key
cidr_block = each.valuetags = {
Name = "my-public-subnet-${each.key}-${terraform.workspace}"
Tier = "public"
}
}# Private Subnet
resource "aws_subnet" "private_subnet" {
for_each = var.az_private_subnetvpc_id = aws_vpc.main.idavailability_zone = each.key
cidr_block = each.valuetags = {
Name = "my-private-subnet-${each.key}-${terraform.workspace}"
Tier = "private"
}
}# Database Subnet
resource "aws_subnet" "database_subnet" {
for_each = var.az_database_subnetvpc_id = aws_vpc.main.idavailability_zone = each.key
cidr_block = each.valuetags = {
Name = "my-database-subnet-${each.key}-${terraform.workspace}"
Tier = "database"
}
}
You may run terraform apply
at this point to incrementally update our environment.
$ terraform apply -var-file=env/dev/vpc.tfvars
Elastic IP Address and NAT Gateway
EC2 instances provisioned in the private subnet might need to connect to the public internet. A NAT Gateway allows these instances in the private subnet to initiate outside connection to the internet, or other external AWS services. Add these resources to main.tf
.
# EIP
resource "aws_eip" "nat" {
vpc = true
depends_on = [aws_internet_gateway.gw]
}# NAT Gateway
resource "aws_nat_gateway" "gw" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_subnet[var.ec2_instance_az].id
}
Route Table and Route Table Association
Now that we have all AWS resources defined for our VPC, it’s time to connect them all together using a route table.
A route table contains a set of rules, called routes, that are used to determine where network traffic from your subnet or gateway is directed.
Open main.tf
and add these resources.
# public subnet route table
resource "aws_route_table" "public_subnet_route_table" {
vpc_id = aws_vpc.main.idroute {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}tags = {
Name = "my-public-subnet-route-table"
}
}# public subnet route table association
resource "aws_route_table_association" "public_subnet_route_table_association" {
for_each = var.az_public_subnetsubnet_id = aws_subnet.public_subnet[each.key].id
route_table_id = aws_route_table.public_subnet_route_table.id
}# private subnet route table
resource "aws_route_table" "private_subnet_route_table" {
vpc_id = aws_vpc.main.idroute {
cidr_block = "0.0.0.0/0"
gateway_id = aws_nat_gateway.gw.id
}tags = {
Name = "my-private-subnet-route-table"
}
}# private subnet route table association
resource "aws_route_table_association" "private_subnet_route_table_association" {
for_each = var.az_private_subnetsubnet_id = aws_subnet.private_subnet[each.key].id
route_table_id = aws_route_table.private_subnet_route_table.id
}# database subnet route table
resource "aws_route_table" "database_subnet_route_table" {
vpc_id = aws_vpc.main.idroute {
cidr_block = "0.0.0.0/0"
gateway_id = aws_nat_gateway.gw.id
}tags = {
Name = "my-database-subnet-route-table"
}
}# database subnet route table association
resource "aws_route_table_association" "database_subnet_route_table_association" {
for_each = var.az_database_subnetsubnet_id = aws_subnet.database_subnet[each.key].id
route_table_id = aws_route_table.database_subnet_route_table.id
}
Run terraform apply
. Open the AWS Console and verify if these resources were properly provisioned. You should see 3 route tables: a public, private, and database route tables. Each route table should be associated to 3 subnets. Here’s mine.
Public route table with 3 public subnets associated
Public route table route destination. Non-local traffic is routed to the Internet Gateway.
Private and Database route table route destination. Notice that the non-local traffic is routed to the NAT Gateway instead of the Internet Gateway.
Spinning up AWS EC2 instances
The last step is to create an EC2 instance in the public subnet, which will serve as our bastion host.
First, open variables.tf
and add the map variable declaration below.
variable "ec2_instance_az" {
type = string
}
Open env/dev/vpc.tfvars
and set the key-value pair for the DEV
workspace. This will be used to specify which public subnet availability zone we will provision the EC2 instance.
ec2_instance_az = "ap-southeast-2a"
Next is to create a key pair to connect to the EC2 instance. Create it manually by following the steps in the AWS documentation. Use the Sydney region for our DEV
workspace. Take note of the key pair name.
Create a new file server.tf
and add the script below. Replace the key_name = "my-keypair"
value with your key pair name from the previous step.
data "aws_ami" "ubuntu" {
most_recent = truefilter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-bionic-18.04-amd64-server-*"]
}filter {
name = "virtualization-type"
values = ["hvm"]
}owners = ["099720109477"] # Canonical
}resource "aws_security_group" "bastion_host_sg" {
name = "bastion_host_sg"
vpc_id = aws_vpc.main.idingress {
from_port = 22
to_port = 22
protocol = "tcp"// Not the safest thing to do, but you can replace
// this with your public IP address - x.x.x.x.x/32
cidr_blocks = ["0.0.0.0/0"]
}egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}tags = {
Name = "my-bastion-host-security-group"
}
}resource "aws_instance" "bastion_host" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"subnet_id = aws_subnet.public_subnet[var.ec2_instance_az].id
associate_public_ip_address = true
key_name = "my-keypair"
vpc_security_group_ids = [aws_security_group.bastion_host_sg.id]tags = {
Name = "My Bastion Host"
}
}
In the script above, we used a Terraform data source aws_ami. A data source is a read-only representation of a pre-existing data, in this case, the Ubuntu AMI from Canonical.
We also used an EC2 security group and EC2 instance.
Run terraform apply
to create the EC2 instance.
$ terraform apply -var-file=env/dev/vpc.tfvars
Connect to your instance and ping amazon.com.
Terraform Output
A convenient way to display important details of the provisioned resources is using Terraform output. Open outputs.tf
and add the codes below.
output "my-aws-instance-public-ip" {
value = aws_instance.bastion_host.public_ip
}
This will get the public ip address of the EC2 instance bastion host.
$ terraform output
my-aws-instance-public-ip = 3.105.229.143
Removing Terraform-managed resources
❌ DO NOT SKIP THIS STEP! Terminate all created resources to stop your account from incurring charges.
Destroy all created resources in the DEV
workspace.
$ terraform workspace select dev
$ terraform destroy -var-file=env/dev/vpc.tfvars
If you used the other workspaces, then make sure you destroy it all too.
# UAT
$ terraform workspace select uat
$ terraform destroy -var-file=env/uat/vpc.tfvars# PROD
$ terraform workspace select prod
$ terraform destroy -var-file=env/prod/vpc.tfvars
Project Codes
Download the complete codes in https://github.com/carlocayos/terraform-vpc-tutorial.
What’s Next?
When creating the VPC using Terraform, it is apparent there are a lot of boilerplate codes. This can be simplified by using a third party Terraform AWS VPC module. In the next and final post, we will see how we can use the module to simplify the AWS VPC creation using Terraform.
Disclaimer: All views expressed on this site are my own and do not represent the opinions of any entity whatsoever with which I have been, am now, or will be affiliated
Originally published at carlocayos.com. Updated to Terraform version 0.12.29.