Table of Contents

This is the fourth and final part of a series of articles on how to set up an AWS VPC using Terraform version 0.12.29.

Up until now we are setting up our VPC using Terraform the hard way. There is a better and easier way to provision the AWS resources using the third-party Terraform AWS VPC module.

In this post, we will create a new Terraform project with significantly fewer codes. It should run as a standalone project, with no dependency on the steps we did from Parts 1, 2, and 3.

VPC Diagram

  • AWS VPC
  • 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 diagram below is to help you visualize what we will create.

Set up a Terraform Project with AWS Provider

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

Create a new project folder with files main.tf, outputs.tf, variables.tf. Then create a directory env/dev, env/uat, env/prod, each will have vpc.tfvars file.

Open the file main.tf and add the AWS Provider.

#-------------------------------
# AWS Provider
#-------------------------------
provider "aws" {
  region = var.aws_region
}

Initialize Terraform by running this command. It should set up the project and download the defined AWS Provider.

$ terraform init

Create a DEV workspace, similar to what we did in Part 2.

$ terraform workspace new dev
$ terraform workspace list

VPC Environment-Specific Variables

Open variables.tf and add these variable declarations.

variable "aws_region" {
  description = "AWS Region"
}
variable "vpc_cidr_block" {
  description = "Main VPC CIDR Block"
}

variable "availability_zones" {
  type = list(string)
}

variable "public_subnet" {
  type = list(string)
}

variable "private_subnet" {
  type = list(string)
}

variable "database_subnet" {
  type = list(string)
}

Open env/dev/vpc.tfvars and set the environment-specific values for the DEV workspace. In here we set the AWS region to Sydney and the VPC configurations.

aws_region         = "ap-southeast-2"
vpc_cidr_block     = "10.0.0.0/16"
availability_zones = ["ap-southeast-2a", "ap-southeast-2b", "ap-southeast-2c"]
public_subnet      = ["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24"]
private_subnet     = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
database_subnet    = ["10.0.201.0/24", "10.0.202.0/24", "10.0.203.0/24"]

Adding the Terraform AWS VPC Module

Now it’s time to demonstrate how to use the AWS VPC Module with our DEV VPC configuration.

Open main.tf file and add the Terraform AWS VPC Module.

#-------------------------------
# AWS VPC
#-------------------------------
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "2.44.0"

  name = "my-vpc-terraform-module-${terraform.workspace}"
  cidr = var.vpc_cidr_block

  azs              = var.availability_zones
  public_subnets   = var.public_subnet
  private_subnets  = var.private_subnet
  database_subnets = var.database_subnet

  create_igw         = true
  enable_nat_gateway = true
  single_nat_gateway = true

  tags = {
    Terraform   = "true"
    Environment = terraform.workspace
  }
}

Run terraform init to download the module.

Inspect the codes we have added. In just around 20 lines we have defined the VPC configuration similar to what we did in Parts 1-3. But this time we let the module handle all the boilerplate codes!

We only used the VPC module inputs we needed for this example. An exhaustive list of the inputs can be found in Terraform AWS VPC Module Input

A quick run through in our VPC Module configuration.

  • azs - this is the list of availability zones. We set it to Sydney region AZs.
  • public_subnets - public subnet, set this with CIDR block list
  • private_subnets - private subnet, set this with CIDR block list
  • database_subnets - database subnet, set this with CIDR block list
  • create_igw - flag to indicate creation of an Internet Gateway, default is true
  • enable_nat_gateway - set to true to provision a NAT Gateway
  • single_nat_gateway - set to true to provision a single shared NAT Gateway, otherwise it will create a NAT gateway in each public subnet
  • tags - a map of tags to add to ALL taggable resources created through this module

And that’s it! All of these in just 20 lines of codes. Let’s now provision these resources, run these commands.

$ terraform workspace select dev
$ terraform apply -var-file=env/dev/vpc.tfvars

In the logs, you should see that Terraform will create the VPC with 27 AWS resources.

+ resource "aws_db_subnet_group" "database"...
+ resource "aws_eip" "nat"...
+ resource "aws_internet_gateway" "this"...
+ resource "aws_nat_gateway" "this"...
+ resource "aws_route" "private_nat_gateway"...
+ resource "aws_route" "public_internet_gateway"...
+ resource "aws_route_table" "private"...
+ resource "aws_route_table" "public"...
+ resource "aws_route_table_association" "database"...
+ resource "aws_route_table_association" "database"...
+ resource "aws_route_table_association" "database"...
+ resource "aws_route_table_association" "private"...
+ resource "aws_route_table_association" "private"...
+ resource "aws_route_table_association" "private"...
+ resource "aws_route_table_association" "public"...
+ resource "aws_route_table_association" "public"...
+ resource "aws_route_table_association" "public"...
+ resource "aws_subnet" "database"...
+ resource "aws_subnet" "database"...
+ resource "aws_subnet" "database"...
+ resource "aws_subnet" "private"...
+ resource "aws_subnet" "private"...
+ resource "aws_subnet" "private"...
+ resource "aws_subnet" "public"...
+ resource "aws_subnet" "public"...
+ resource "aws_subnet" "public"...
+ resource "aws_vpc" "this"...

Apply complete! Resources: 27 added, 0 changed, 0 destroyed.

Terraform Output

The module also provides information of the created resources through the Terraform AWS VPC Module Output.

To demonstrate how to use it, create the file outputs.tf and add these codes.

output "vpc_id" {
  value = module.vpc.vpc_id
}

output "vpc_cidr_block" {
  value = module.vpc.vpc_cidr_block
}

output "azs" {
  value = module.vpc.azs
}

output "private_subnets_cidr_blocks" {
  value = module.vpc.private_subnets_cidr_blocks
}

output "public_subnets_cidr_blocks" {
  value = module.vpc.public_subnets_cidr_blocks
}

output "database_subnets_cidr_blocks" {
  value = module.vpc.database_subnets_cidr_blocks
}

Apply the output changes.

$ terraform apply -var-file=env/dev/vpc.tfvars
$ terraform output

You should see details of your cloud infrastructure as what you have defined in the outputs.tf file.

azs = [
  "ap-southeast-2a",
  "ap-southeast-2b",
  "ap-southeast-2c",
]
...
vpc_cidr_block = 10.0.0.0/16
vpc_id = vpc-0918ff5d446d5a6b4

Helper Script

Up until now we are running the commands like so:

$ terraform workspace select dev
$ terraform apply -var-file=env/dev/vpc.tfvars

This 2-step process is prone to error as you might run the command terraform apply with the variable for dev environment in an incorrect workspace (i.e. uat or prod).

I made a simple helper script to avoid this type of human error. Create a new file terraform_helper.sh and copy this script.

#!/usr/bin/env bash

#-----------------------------------
# Check valid subcommand
#-----------------------------------
subcommand_arr=("plan" "refresh" "apply" "destroy")

valid_subcommand=0

for c in "${subcommand_arr[@]}"
do
    if [[ "${c}" == "$1" ]]
    then
        valid_subcommand=1
        break
    fi
done

# Check if subcommand is valid
if [[ ${valid_subcommand} -eq 0 ]]
    then
        echo "Invalid subcommand.\n Usage: './terraform_helper.sh <subcommand> <workspace>'"
        exit 1
fi

#-----------------------------------
# Check valid workspace
#-----------------------------------
workspace_arr=(`ls -1 env`)

valid_workspace=0

for workspace in "${workspace_arr[@]}"
do
    if [[ "${workspace}" == "$2" ]]
    then
        valid_workspace=1
        break
    fi
done

# Check if workspace is valid
if [[ ${valid_workspace} -eq 0 ]]
    then
        echo "Invalid argument.\n Usage: './terraform_helper.sh <subcommand> <workspace>'"
        exit 1
fi

# Set the workspace
workspace_command="terraform workspace select $2"
echo "Selecting workspace... ${workspace_command}\n"

eval ${workspace_command}
if [ $? -ne 0 ]; then
    exit 1
fi

# Run the command
terraform_command="terraform $1 -var-file=env/$2/vpc.tfvars"
echo "Running command... ${terraform_command}\n"

eval ${terraform_command}
if [ $? -ne 0 ]; then
    exit 1
fi

The script accepts two arguments; a <subcommand> (“plan” “refresh” “apply” “destroy”), and the target <workspace>. It would change to the target workspace and set the environment-specific values to the variables. One caveat is that the workspace name (e.g. dev) should match the directory where you store the variables (e.g. env/dev/vpc.tfvars).

$ ./terraform_helper.sh apply dev

Spinning up AWS EC2 instances

The information from the Terraform AWS VPC Module Output we discussed in the previous section can also be used to create other resources. In this section, we will create an EC2 instance bastion host using the information from the module output.

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.

data "aws_ami" "ubuntu" {
  most_recent = true

  filter {
    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 = module.vpc.vpc_id

  ingress {
    from_port = 22
    to_port   = 22
    protocol  = "tcp"

    // Not the safest thing to do, but you can replace this with your public IP
    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-p4"
  }
}

resource "aws_instance" "bastion_host" {
  ami                         = data.aws_ami.ubuntu.id
  instance_type               = "t3.micro"
  subnet_id                   = module.vpc.public_subnets[0]
  associate_public_ip_address = true
  key_name                    = "my-keypair" // change this
  vpc_security_group_ids      = [aws_security_group.bastion_host_sg.id]
  tags = {
    Name = "My Bastion Host - Part 4"
  }
}

Inspect the codes, you will notice that there are two lines where we use the module output to set the resource meta-argument.

1) In the aws_security_group resource
vpc_id = module.vpc.vpc_id

2) In the aws_instance resource
subnet_id = module.vpc.public_subnets[0]

Run terraform apply to create the EC2 instance.

$ terraform apply -var-file=env/dev/vpc.tfvars

Or if you are using the helper script.

$ ./terraform_helper.sh apply dev

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

Or if you are using the helper script.

$ ./terraform_helper.sh destroy dev

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

Review

This ends the tutorial series on Terraform and how to use it to create a shared and configurable infrastructure as code project.

By now you should be familiar with Terraform, its syntax, the available providers, and its resources. We started doing it the hard way to cover the basics of Terraform. Then we used a third-party AWS VPC module to demonstrate how it can significantly reduce the lines of codes in our project.

Next step is for you to explore the other Terraform providers and create your own module. It might be a combination of other modules or something you created by yourself.

Hope that you find my Terraform tutorial series informative.

Project Codes

Download the complete codes in https://github.com/carlocayos/terraform-vpc-module-tutorial.