Terraform & Terragrunt to Create a VPC and its Components (Part I)

Stéphane Noutsa
6 min readJul 3, 2023
Infrastructure as Code (IaC) with Terraform

In the era of cloud computing, infrastructure as code (IaC) has gained immense popularity due to its ability to provision and manage infrastructure resources in a consistent and automated manner. Terraform, an open-source IaC tool by HashiCorp, has emerged as a leading choice for provisioning and managing cloud resources, including those offered by Amazon Web Services (AWS).

In this article, we will explore the process of using Terraform to create basic AWS modules (which we’ll call building blocks), enabling you to deploy and manage infrastructure easily and efficiently. We’ll create the following building blocks:

  1. A VPC (obviously)
  2. An internet gateway
  3. A route table
  4. A subnet
  5. An elastic IP (EIP)
  6. A NAT gateway
  7. A NACL for the subnets

This is the first article in a 2-part series on how to use Terraform and Terragrunt to create a VPC with its components, and it assumes some familiarity with Terraform.

0. Common Files
Our Terraform building blocks will be independent projects which will, however, share common files — the provider.tf and variables.tf files.

variables.tf

variable "AWS_ACCESS_KEY_ID" {
type = string
}

variable "AWS_SECRET_ACCESS_KEY" {
type = string
}

variable "AWS_REGION" {
type = string
}

It should be noted that each building block will add more variables to its variables.tf file depending on its requirements.

provider.tf

terraform {
required_version = ">= 1.4.2"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.0"
}
}
}

provider "aws" {
access_key = var.AWS_ACCESS_KEY_ID
secret_key = var.AWS_SECRET_ACCESS_KEY
region = var.AWS_REGION
}
  1. VPC

The VPC will be the main building block that will contain all the other building blocks.
(Take note of the output section which outputs the VPC’s ID)

main.tf

resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr
instance_tenancy = var.instance_tenancy
enable_dns_support = var.enable_dns_support
enable_dns_hostnames = var.enable_dns_hostnames
assign_generated_ipv6_cidr_block = var.assign_generated_ipv6_cidr_block

tags = merge(var.vpc_tags, {
Name = var.vpc_name
})
}

output "vpc_id" {
value = aws_vpc.vpc.id
}

variables.tf (additional variables)

variable "vpc_cidr" {
type = string
}

variable "vpc_name" {
type = string
}

variable "instance_tenancy" {
type = string
default = "default"
}

variable "enable_dns_support" {
type = bool
default = true
}

variable "enable_dns_hostnames" {
type = bool
}

variable "assign_generated_ipv6_cidr_block" {
type = bool
default = false
}

variable "vpc_tags" {
type = map(string)
}

2. Internet Gateway

The internet gateway will allow two-way communication between the internet and resources in the VPC (in the public subnet to be more precise).

main.tf

resource "aws_internet_gateway" "igw" {
vpc_id = var.vpc_id

tags = merge(var.tags, {
Name = var.name
})
}

output "igw_id" {
value = aws_internet_gateway.igw.id
}

variables.tf (additional variables)

variable "vpc_id" {
type = string
}

variable "name" {
type = string
}

variable "tags" {
type = map(string)
}

3. Route Table

The route table will have a route that will allow resources in public and private subnets to communicate with the Internet through an Internet Gateway (for public subnets) or a NAT Gateway (for private subnets).

main.tf

resource "aws_route_table" "route_tables" {
for_each = { for rt in var.route_tables : rt.name => rt }

vpc_id = each.value.vpc_id

dynamic "route" {
for_each = { for route in each.value.routes : route.cidr_block => route if each.value.is_igw_rt }

content {
cidr_block = route.value.cidr_block
gateway_id = route.value.igw_id
}
}

dynamic "route" {
for_each = { for route in each.value.routes : route.cidr_block => route if !each.value.is_igw_rt }

content {
cidr_block = route.value.cidr_block
nat_gateway_id = route.value.nat_gw_id
}
}

tags = merge(each.value.tags, {
Name = each.value.name
})
}

output "route_table_ids" {
value = values(aws_route_table.route_tables)[*].id
}

The route blocks use conditional statements to determine which entries to create for the route table.

variables.tf (additional variables)

variable "route_tables" {
type = list(object({
name = string
vpc_id = string
is_igw_rt = bool

routes = list(object({
cidr_block = string
igw_id = optional(string)
nat_gw_id = optional(string)
}))

tags = map(string)
}))
}

4. Subnet

This building block will allow the creation of public subnets or private subnets, depending on the values passed to its variables.

main.tf

# Create public subnets
resource "aws_subnet" "public_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if subnet.is_public }

vpc_id = each.value.vpc_id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
map_public_ip_on_launch = each.value.map_public_ip_on_launch
private_dns_hostname_type_on_launch = each.value.private_dns_hostname_type_on_launch

tags = merge(each.value.tags, {
Name = each.value.name
})
}

# Associate public subnets with their route table
resource "aws_route_table_association" "public_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if subnet.is_public }

subnet_id = aws_subnet.public_subnets[each.value.name].id
route_table_id = each.value.route_table_id
}

# Create private subnets
resource "aws_subnet" "private_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if !subnet.is_public }

vpc_id = each.value.vpc_id
cidr_block = each.value.cidr_block
availability_zone = each.value.availability_zone
private_dns_hostname_type_on_launch = each.value.private_dns_hostname_type_on_launch

tags = merge(each.value.tags, {
Name = each.value.name
})
}

# Associate private subnets with their route table
resource "aws_route_table_association" "private_subnets" {
for_each = { for subnet in var.subnets : subnet.name => subnet if !subnet.is_public }

subnet_id = aws_subnet.private_subnets[each.value.name].id
route_table_id = each.value.route_table_id
}

variables.tf (additional variables)

variable "subnets" {
type = list(object({
name = string
vpc_id = string
cidr_block = string
availability_zone = optional(string)
map_public_ip_on_launch = optional(bool, true)
private_dns_hostname_type_on_launch = optional(string, "resource-name")
is_public = optional(bool, true)
route_table_id = string
tags = map(string)
}))
}

5. Elastic IP (EIP)

The EIP can be used to assign a static public IP to a resource such as an EC2 instance or a NAT Gateway. In our case, it will be attached to the NAT Gateway that we’ll create.

main.tf

resource "aws_eip" "eip" {
vpc = var.in_vpc

tags = merge(var.tags, {})
}

output "eip_id" {
value = aws_eip.eip.id
}

variables.tf (additional variables)

variable "in_vpc" {
type = bool
}

variable "tags" {
type = map(string)
}

6. Network Address Translation (NAT) Gateway

The NAT Gateway will allow resources in private subnets to make one-way requests to the Internet (and receive responses). It has to be placed in a public subnet and should have a public IP address (which is why an EIP will first be created).

main.tf

resource "aws_nat_gateway" "nat_gw" {
allocation_id = var.eip_id
subnet_id = var.subnet_id

tags = merge(var.tags, {
Name = var.name
})
}

output "nat_gw_id" {
value = aws_nat_gateway.nat_gw.id
}

variables.tf (additional variables)

variable "name" {
type = string
}

variable "eip_id" {
type = string
}

variable "subnet_id" {
type = string
description = "The ID of the public subnet in which the NAT Gateway should be placed"
}

variable "tags" {
type = map(string)
}

7. Network Access Control List (NACL)

The NACL acts like a firewall at the subnet level, allowing or denying traffic into or out of the subnet.
After a NACL is created, it has to be associated with a subnet before it can start filtering its traffic.

main.tf

resource "aws_network_acl" "nacls" {
for_each = { for nacl in var.nacls : nacl.name => nacl }

vpc_id = each.value.vpc_id

dynamic "egress" {
for_each = { for rule in each.value.egress : rule.rule_no => rule }

content {
protocol = egress.value.protocol
rule_no = egress.value.rule_no
action = egress.value.action
cidr_block = egress.value.cidr_block
from_port = egress.value.from_port
to_port = egress.value.to_port
}
}

dynamic "ingress" {
for_each = { for rule in each.value.ingress : rule.rule_no => rule }

content {
protocol = ingress.value.protocol
rule_no = ingress.value.rule_no
action = ingress.value.action
cidr_block = ingress.value.cidr_block
from_port = ingress.value.from_port
to_port = ingress.value.to_port
}
}

tags = merge(each.value.tags, {
Name = each.value.name
})
}

resource "aws_network_acl_association" "nacl_associations" {
for_each = { for nacl in var.nacls : "${nacl.name}_${nacl.subnet_id}" => nacl }

network_acl_id = aws_network_acl.nacls[each.value.name].id
subnet_id = each.value.subnet_id
}

variables.tf

variable "nacls" {
type = list(object({
name = string
vpc_id = string
egress = list(object({
protocol = string
rule_no = number
action = string
cidr_block = string
from_port = number
to_port = number
}))
ingress = list(object({
protocol = string
rule_no = number
action = string
cidr_block = string
from_port = number
to_port = number
}))
subnet_id = string
tags = map(string)
}))
}

Conclusion

After creating all these building blocks, we can proceed to orchestrate the creation of a VPC with its components depending on its specific needs. This orchestration can be done using another IaC tool called Terragrunt. We’ll look at that in the second (and last) part of this series.

Happy coding!

--

--

Stéphane Noutsa

DevOps Engineer | 4x AWS | Terraform | Ansible | Docker | Kubernetes