How to build a 3-tier architecture with AWS and Terraform—Part 2

David Essien Avatar
Diagram for three-their web application with aws and terraform

Part 2: Setting Up the Infrastructure

In a previous article, we looked at an introduction to the 3-tier architecture and outlined the design and benefits. In this part, we will be looking at setting up the infrastructure for the 3-tier architecture.

This project is made up of 9 parts:

The project code can be found here

Before building the three-tier architecture, we need a robust foundation. This involves creating the Virtual Private Cloud (VPC), subnets, internet gateways, and NAT gateways. These resources define the networking and connectivity of your environment, ensuring secure and efficient communication between tiers.

Step 1: Virtual Private Cloud (VPC)

What is a VPC?

VPC (Virtual Private Cloud) is an isolated private cloud within a public cloud. A public cloud is cloud infrastructure that is shared among multiple users. Creating a VPC is like having your own flat in a public building with several flats. Your space is private; you can set up your properties and decide who has access or not.

In AWS, a VPC is a logically isolated network where you can define resources such as subnets, gateways, and security groups. It acts as a private cloud for your infrastructure. It achieves this privacy and isolation by using virtual network tools like security groups and other network functionalities. That way, only permitted entities can gain access to whatever resources are created within the VPC.

Terraform Configuration for VPC

variable "vpcs" {
  description = "Map of VPC configurations"
  type = map(object({
    cidr_block                       = string
    enable_dns_hostnames             = bool
    assign_generated_ipv6_cidr_block = bool
    tags                              = map(string)
  }))
}

module "vpc" {
  for_each = var.vpcs

  source = "github.com/davidshare/terraform-aws-modules//vpc?ref=vpc-v1.0.0"

  cidr_block                       = each.value.cidr_block
  enable_dns_hostnames             = each.value.enable_dns_hostnames
  assign_generated_ipv6_cidr_block = each.value.assign_generated_ipv6_cidr_block
  tags                             = each.value.tags
}

Explanation of Arguments

  • cidr_block: Defines the IP address range for the VPC. Example: 10.0.0.0/16 allows 65,536 IP addresses.
  • enable_dns_hostnames: Enables DNS hostnames for instances. Useful for accessing services using names instead of IPs.
  • assign_generated_ipv6_cidr_block: Assigns an IPv6 range to the VPC for dual-stack networking.
  • tags: Key-value pairs for identifying and organizing resources.

Example Input

vpcs = {
  main = {
    cidr_block                       = "10.0.0.0/16"
    enable_dns_hostnames             = true
    assign_generated_ipv6_cidr_block = true
    tags = {
      Name = "MyVPC"
    }
  }
}

Example output

aws dashboard showing an AWS VPC

Step 2: Subnets

What are Subnets?

Again, for subnets, imagine that your flat in the public building of flats has several rooms. Each room can contain different furniture and can be used for different purposes. Also, you can control who goes into the room or not by using a lock on the door.

Subnets are smaller networks within the VPC. Each subnet exists within a specific availability zone. Whatever resources you create within that subnet will exist only within it’s availability zone, and you can control access to these resources.

In this project, we create 6 subnets. Two public subnets for the frontend tier, 2 private subnets for the backend tier, and 2 private subnets for the database tier.

Terraform Configuration for Subnets

variable "subnets" {
  description = "Map of subnets with their configuration"
  type = map(object({
    cidr_block        = string
    availability_zone = string
    public            = bool
    vpc_name          = string
    tags              = map(string)
  }))
}

# Module call for subnets
module "subnets" {
  for_each = var.subnets

  source = "github.com/davidshare/terraform-aws-modules//subnet?ref=subnet-v1.0.0"

  vpc_id                  = module.vpc[each.value.vpc_name].id
  cidr_block              = each.value.cidr_block
  availability_zone       = each.value.availability_zone
  map_public_ip_on_launch = each.value.public
  tags                    = merge(each.value.tags, local.tags)
}

Explanation of Arguments

  • cidr_block: The IP range for the subnet. Example: 10.0.1.0/24 allows 256 IP addresses.
  • availability_zone: Specifies the AWS region’s zone (e.g., us-east-1a) for fault tolerance.
  • public: Indicates whether a public IP address will be automatically assigned to the EC2 instances created within the subnet. It is used to keep the subnet private by ensuring that there is no access to it from the internet.
  • vpc_name: Links the subnet to its parent VPC using the module.vpc output.
  • tags: Helps organize subnets.

Example Input

subnets = {
  "primary_public_1" = {
    cidr_block        = "10.0.1.0/24"
    availability_zone = "us-east-1a"
    public            = true
    vpc_name          = "main"
    tags = {
      Name = "PrimaryPublicSubnet1"
    }
  },
  "primary_public_2" = {
    cidr_block        = "10.0.2.0/24"
    availability_zone = "us-east-1b"
    public            = true
    vpc_name          = "main"
    tags = {
      Name = "PrimaryPublicSubnet2"
    }
  },
  "primary_backend_private_1" = {
    cidr_block        = "10.0.3.0/24"
    availability_zone = "us-east-1a"
    public            = false
    vpc_name          = "main"
    tags = {
      Name = "PrimaryBackendPrivateSubnet1"
    }
  },
  "primary_backend_private_2" = {
    cidr_block        = "10.0.4.0/24"
    availability_zone = "us-east-1b"
    public            = false
    vpc_name          = "main"
    tags = {
      Name = "PrimaryBackendPrivateSubnet2"
    }
  },
  "primary_db_private_1" = {
    cidr_block        = "10.0.5.0/24"
    availability_zone = "us-east-1a"
    public            = false
    vpc_name          = "main"
    tags = {
      Name = "PrimaryDBPrivateSubnet1"
    }
  },
  "primary_db_private_2" = {
    cidr_block        = "10.0.6.0/24"
    availability_zone = "us-east-1b"
    public            = false
    vpc_name          = "main"
    tags = {
      Name = "PrimaryDBPrivateSubnet2"
    }
  }
}

Example Output:

AWS subnets created in an aws vpc.

Step 3: Internet Gateway

What is an Internet Gateway?

An Internet Gateway (IGW) connects your VPC to the public internet, allowing resources in public subnets to access external services.

Remember that our VPC is private. So, the IGW is what helps us get internet access. For this project, we only need one IGW for our VPC.

Terraform Configuration for IGW

variable "internet_gateways" {
  description = "Map of Internet Gateway configurations"
  type = map(object({
    vpc  = string
    tags = map(string)
  }))
}

module "internet_gateway" {
  for_each = var.internet_gateways

  source = "github.com/davidshare/terraform-aws-modules//internet_gateway?ref=internet_gateway-v1.0.0"

  vpc_id = module.vpc[each.value.vpc].id
  tags   = each.value.tags
}

Explanation of Arguments

  • vpc: Links the Internet Gateway to a specific VPC.
  • tags: Metadata to identify the gateway.

Example Input

internet_gateways = {
  main = {
    vpc  = "main"
    tags = {
      Name = "Main Internet Gateway"
    }
  }
}

Example output

AWS Internet gateway

Step 4: NAT Gateway

What is a NAT Gateway?

A NAT Gateway enables private subnets to access the internet for updates or external APIs while preventing inbound traffic. The NAT Gateways are always created in public subnets since the public subnets have access to the internet.

In this project, we created two NAT gateways, one for each availability zone. This is because the subnets are limited to availability zones.

Terraform Configuration for NAT Gateway

variable "nat_gateways" {
  description = "Map of NAT Gateway configurations"
  type = map(object({
    vpc        = string
    elastic_ip = string
    subnet     = string
    tags       = map(string)
  }))
}

module "nat_gateway" {
  for_each = var.nat_gateways

  source = "github.com/davidshare/terraform-aws-modules//nat_gateway?ref=nat_gateway-v1.0.0"

  allocation_id = module.elastic_ip[each.value.elastic_ip].allocation_id
  subnet_id     = module.subnets[each.value.subnet].id
  tags          = each.value.tags

  depends_on = [module.elastic_ip]
}

Explanation of Arguments

  • elastic_ip: Allocates a static public IP for the NAT Gateway.
  • subnet: Places the NAT Gateway in a specific subnet (usually public).
  • tags: Helps identify the gateway.

Example Input

nat_gateways = {
  primary_backend_private_1 = {
    vpc        = "main"
    elastic_ip = "primary_backend_private_1"
    subnet     = "primary_public_1"
    tags = {
      Name        = "Backend NAT 1"
      Description = "Natgateway 1"
    }
  },
  primary_backend_private_2 = {
    vpc        = "main"
    elastic_ip = "primary_backend_private_2"
    subnet     = "primary_public_2"
    tags = {
      Name        = "Backend NAT 2"
      Description = "Natgateway 2"
    }
  }
}

Example Output:

aws nat gateways

Step 5: Route Tables

What are Route Tables?

Route tables define how traffic is routed within a VPC and between the internet, subnets, and other resources.

A route table is like a waiter in a restaurant that directs people to their seats. When traffic gets to the VPC, based on already set rules, the route table will direct the traffic to the appropriate subnet and resources.

Terraform Configuration for Route Tables

variable "route_tables" {
  description = "Map of route table configurations"
  type = map(object({
    vpc  = string
    tags = map(string)
  }))
}

module "route_table" {
  for_each = var.route_tables

  source = "github.com/davidshare/terraform-aws-modules//route_table?ref=route_table-v1.0.0"

  vpc_id = module.vpc[each.value.vpc].id
  tags   = each.value.tags
}

Explanation of Arguments

  • vpc: The VPC that the route table will be created in.
  • tags: Metadata to identify the gateway.

Example Input

route_tables = {
  "primary_public" = {
    vpc = "main"
    tags = {
      Name        = "Frontend"
      Description = "route table for the public subnets"
    }
  },
  "primary_backend_private" = {
    vpc = "main"
    tags = {
      Name        = "Backend in AZ A"
      Description = "route table for the private subnets in one AZ A"
    }
  },
  "primary_db_private" = {
    vpc = "main"
    tags = {
      Name        = "Backend in AZ B"
      Description = "route table for the private subnets in one AZ B"
    }
  }
}

Example outputs

aws route tables

Step 6: Routes

What are Routes?

Routes are the rules that determine how traffic is routed in your network. Each route is part of a Route Table, and the table determines where traffic is directed.

As you can see from the terraform configuration below, we separated the rules for the NAT gateway (private traffic) from those for the Internet gateway (public traffic). Each rule has an associated route table, the CIDR range, and the gateway.

Terraform Configuration for Routes

variable "internet_gateway_routes" {  
  description = "Map of routes for Internet Gateway"
  type = map(object({
    route_table = string
    cidr        = string
    gateway     = string
}))}

variable "nat_gateway_routes" {  
  description = "Map of routes for NAT Gateway"  
  type = map(object({    
    route_table = string    
    cidr        = string    
    gateway     = string  
}))}

module "internet_gateway_routes" {
  for_each = var.internet_gateway_routes

  source = "github.com/davidshare/terraform-aws-modules//route?ref=route-v1.0.0"

  route_table_id         = module.route_table[each.value.route_table].id
  destination_cidr_block = each.value.cidr
  gateway_id             = module.internet_gateway[each.value.gateway].id

  depends_on = [module.internet_gateway, module.route_table]
}

module "nat_gateway_routes" {
  for_each = var.nat_gateway_routes

  source = "github.com/davidshare/terraform-aws-modules//route?ref=route-v1.0.0"

  route_table_id         = module.route_table[each.value.route_table].id
  destination_cidr_block = each.value.cidr
  nat_gateway_id         = module.nat_gateway[each.value.gateway].id

  depends_on = [module.nat_gateway, module.route_table]
}

Explanation of Key Arguments

  • route_table: Specifies which route table the route is associated with.
  • destination_cidr_block: The CIDR range that matches the traffic for this route. 0.0.0.0/0 represents all IPv4 traffic.
  • gateway_id: The ID of the Internet Gateway for public subnets.
  • nat_gateway_id: The ID of the NAT Gateway for private subnets.
  • destination_ipv6_cidr_block: Same as destination_cidr_block, but for IPv6 traffic.
  • vpc_peering_connection_id: Used to route traffic to a peered VPC.
  • transit_gateway_id: Used to route traffic through a Transit Gateway.

Example Input

internet_gateway_routes = {
  public-web = {
    route_table = "primary_public"
    cidr        = "0.0.0.0/0"
    gateway     = "main"
  }
}

nat_gateway_routes = {
  primary_backend_private_1 = {
    route_table = "primary_backend_private"
    cidr        = "0.0.0.0/0"
    gateway     = "primary_backend_private_1"
  },
  primary_backend_private_2 = {
    route_table = "primary_db_private"
    cidr        = "0.0.0.0/0"
    gateway     = "primary_backend_private_2"
  },
}

Step 7: Route table association

What is a route table association?

A Route Table Association is used to assign subnets to route tables within a VPC. By associating a route table with specific subnets, you define how traffic is routed for those subnets. For example:

  • Private subnets are associated with route tables that use a NAT Gateway for internet access or have no internet access at all.
  • Public subnets are associated with route tables that have routes to an Internet Gateway (IGW).

Terraform Configuration for Routes Table Association

variable "subnets_route_table_association" {
  description = "Map of subnet to route table associations"
  type = map(object({
    subnet      = string
    route_table = string
  }))
}

module "subnets_route_table_associations" {
  for_each = var.subnets_route_table_association

  source = "github.com/davidshare/terraform-aws-modules//route_table_association?ref=route_table_association-v1.0.0"

  subnet_id      = module.subnets[each.value.subnet].id
  route_table_id = module.route_table[each.value.route_table].id

  depends_on = [module.subnets, module.route_table]
}

Explanation of Arguments

  • subnet_id: The ID of the subnet that the route table will be associated with. This is obtained from the subnet module.
  • route_table_id: The ID of the route table to be associated with the subnet.

Example Input

subnets_route_table_association = {
  primary_public_1 = {
    route_table = "primary_public"
    subnet      = "primary_public_1"
  },
  primary_public_2 = {
    route_table = "primary_public"
    subnet      = "primary_public_2"
  },
  primary_backend_private_1 = {
    route_table = "primary_backend_private"
    subnet      = "primary_backend_private_1"
  },
  primary_backend_private_2 = {
    route_table = "primary_backend_private"
    subnet      = "primary_backend_private_2"
  },
  primary_db_private_1 = {
    route_table = "primary_db_private"
    subnet      = "primary_db_private_1"
  },
  primary_db_private_2 = {
    route_table = "primary_db_private"
    subnet      = "primary_db_private_2"
  },
}

Example output

aws route table association

Conclusion

In this section, we’ve established the foundation of our three-tier architecture by creating:

  1. A VPC to isolate our resources.
  2. Subnets for each tier of the architecture.
  3. An Internet Gateway for public communication.
  4. A NAT Gateway for secure outbound traffic from private subnets.
  5. Route Tables to direct network traffic appropriately.
  6. Routes for defining rules for flow of traffic
  7. Route table associations to associate route tables with subnets.

Next, we’ll move on to setting up security layers to protect our resources and define communication rules between tiers.

David Essien Avatar

Please share