Terraform & Terragrunt to Create a VPC and its Components (Part I)
Tags: aws, devops, terraform
Published: 2023-11-03 00:10:06

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** (additional variables)

```
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!