AWS Three-Tier VPC network with Terraform

A three-tier network is an enterprise architecture to deliver the best performance and security to the end-users. Each component of the design is separated into tiers. Reminder, a typical three-tier network consists of a website then the application then the database from an end-user perspective. Not every website automatically works like that. The developers and engineers have to build the web application by separating the user interface from the logic and data. An AWS three-tier VPC network is not too difficult to build in the cloud, either. However, in this post, I’ll be using Terraform and Terragrunt to build and deploy an AWS three-tier VPC network using, of course, VPC, subnets, route tables, network access control lists (NACLs), and few other VPC parts. Next, I’ll share with you how to create the AWS application load balancer (ALB) and the target groups with health checks in another post.

The design

This will be a simple start and in future posts, I’ll add more details. This AWS three-tier VPC network module will create a VPC, subnets, Network Access Control Lists (NACLs), Internet Gateway, NAT Gateways, route tables, Elastic IPs, and few other resources using Terraform and I’ll deploy it with Terragrunt.

Notice there are two NAT gateways, this provides high availability and fault tolerance. This means if the NAT Gateway in the availability zone (AZ) A fails or gets corrupted then the EC2 instances in AZ B will still be able to function as expected. It’s a little bit more costly… it all depends on your requirements.

The Terraform Module

This module will be generic so I can reuse the three-tier VPC network over and over again. By creating a module it makes my main code a lot less, therefore it will be a lot cleaner to view and understand.

What’s Terraform and Terragrunt? Well visit my Intro to Terragrunt and Terraform post first then come back here! You can name your module anything you like… I named my three-tier-vpc. Reminder in Terraform we can use one or more .tf files to build a module. I’ll be separating this module into few different Terraform files just for organizational purposes. Here’s my structure.

aws/tf-modules/three-tier-vpc
               ├── README.md
               ├── gateways.tf
               ├── main.tf
               ├── nacls.tf
               ├── outputs.tf
               ├── routes.tf
               ├── sec-grps.tf
               └── vars.tf

AWS VPC

Let’s start with the main.tf file which contains the VPC resource. The VPC CIDR will be a variable so we can plugin any CIDR during deployment.

# Create the VPC
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  instance_tenancy     = "default"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(
    var.additional_tags,
    {
      Name = "${var.vpc_name}-vpc"
    }
  )
}

In my module I’m giving the vpc_cidr a default value but you’re not required to do so. This default won’t hurt because if this network range is already taken then terraform apply will fail cleanly.

# vars.tf
variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}

The public subnet CIDR blocks will be variable too. Never hardcode values in modules that could or should be changed. The public_subnet_cidrs is a list of one or many CIDR blocks. If we provide one string value in the block then it will create one public subnet. If we provide four CIDR blocks then it will create four, how sweet is that!

# Create the public subnets
resource "aws_subnet" "public" {
  count = length(var.public_subnet_cidrs)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = "${var.aws_region}${var.zones[count.index]}"
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.vpc_name}-public-subnet-${var.zones[count.index]}"
  }
}

The map_public_ip_on_launch attribute set to true is one of the configurations that makes this a public subnet. Just because we named it public doesn’t make it public. Next are the private subnets. Note the map_public_ip_on_launch is now set to false.

# Create the web subnets
resource "aws_subnet" "web" {
  count = length(var.web_subnet_cidrs)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.web_subnet_cidrs[count.index]
  availability_zone       = "${var.aws_region}${var.zones[count.index]}"
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.vpc_name}-web-subnet-${var.zones[count.index]}"
  }
}

The rest of the main.tf file contains the resources for the data and the app subnets, these subnets are non-public too.

VPC Gateways

In this three-tier VPC network architecture, we have only a few public subnets (meaning the instances launched in the public subnets will get Public IPs; which makes them routable without a NAT gateway). The public subnets will need an internet gateway to complete the design. The private or non-public subnets will need a NAT gateway to allow instances with just private IPs to communicate to the internet (AKA outbound internet access). NAT gateways need 1) a public IP, 2) added to your public subnet for each availability zone you are using!

# Add internet gateway
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = "${var.vpc_name}-internet-gateway"
  }
}

# Charges may occur

# Reserve EIPs
resource "aws_eip" "nat_a" {
  vpc = true

  tags = {
    Name = "${var.vpc_name}-eip-nat-a"
  }

}

# NAT Gateway in AZ A
resource "aws_nat_gateway" "zone_a" {
  allocation_id = aws_eip.nat_a.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${var.vpc_name}-nat-gateway-aza"
  }

  depends_on = [
    aws_subnet.public
  ]
}

# Reserve EIPs
resource "aws_eip" "nat_b" {
  vpc = true

  tags = {
    Name = "${var.vpc_name}-eip-nat-b"
  }

}

# NAT Gateway in AZ B
resource "aws_nat_gateway" "zone_b" {
  allocation_id = aws_eip.nat_b.id
  subnet_id     = aws_subnet.public[1].id

  tags = {
    Name = "${var.vpc_name}-nat-gateway-azb"
  }

  depends_on = [
    aws_subnet.public
  ]
}

Note one of the comments in the code, EIP’s and the gateways may cost you for the duration of their existence.

VPC Routes

Every time a VPC is created the main route table is automatically provisioned, too. I’m going to tag it and create my own route tables for this module. Do note I do have some hardcoded values in this module. I have decided this will be for two AZ’s and no more so I hardcoded the index value of zero and one in the resources below.

# Tag the main route table
resource "aws_ec2_tag" "main_route_table" {
  resource_id = aws_vpc.this.main_route_table_id
  key         = "Name"
  value       = "${var.vpc_name}-main-route-table"
}

# Create route table for the public subnets
# Uses IG
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }

  tags = {
    Name = "${var.vpc_name}-public-route-table"
  }

  depends_on = [
    aws_internet_gateway.this
  ]
}

# Associate the public subnets with the public route table
resource "aws_route_table_association" "public" {
  count = length(var.public_subnet_cidrs)

  subnet_id      = element(aws_subnet.public.*.id, count.index)
  route_table_id = aws_route_table.public.id
}

# Create a route table for the web and app subnets in AZ A
# Uses NAT gateway in AZ A
resource "aws_route_table" "private_aza" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.zone_a.id
  }

  tags = {
    Name = "${var.vpc_name}-private-route-table-aza"
  }

  depends_on = [
    aws_nat_gateway.zone_a
  ]
}

# Create a route table for the web and app subnets in AZ B
# Uses NAT gateway in AZ B
resource "aws_route_table" "private_azb" {
  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.zone_b.id
  }

  tags = {
    Name = "${var.vpc_name}-private-route-table-azb"
  }

  depends_on = [
    aws_nat_gateway.zone_b
  ]
}

resource "aws_route_table" "data" {
  vpc_id = aws_vpc.this.id

  tags = {
    Name = "${var.vpc_name}-data-route-table"
  }
}

# Associate these subnets with the private route tables accordingly 
resource "aws_route_table_association" "web_aza" {
  subnet_id      = aws_subnet.web[0].id
  route_table_id = aws_route_table.private_aza.id
}

resource "aws_route_table_association" "app_aza" {
  subnet_id      = aws_subnet.app[0].id
  route_table_id = aws_route_table.private_aza.id
}

resource "aws_route_table_association" "web_azb" {
  subnet_id      = aws_subnet.web[1].id
  route_table_id = aws_route_table.private_azb.id
}

resource "aws_route_table_association" "app_azb" {
  subnet_id      = aws_subnet.app[1].id
  route_table_id = aws_route_table.private_azb.id
}

resource "aws_route_table_association" "data" {
  count = length(var.data_subnet_cidrs)

  subnet_id      = element(aws_subnet.data.*.id, count.index)
  route_table_id = aws_route_table.data.id
}

The routes are another set of configurations that differentiates public and internal sub-network. In the public subnet route table, all non-local traffic will be sent to the internet gateway. A private route table sends its non-local traffic to the NAT gateway which then routes it to the internet gateway and back.

VPC NACLs

Now it’s time to control which traffic is allowed or denied to this network. You may need to modify these rules to meet your requirements. For most web application projects, the public NACLs will most likely look like this.

# Public NACLS
resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.this.id
  subnet_ids = [aws_subnet.public[0].id, aws_subnet.public[1].id]

  # Ingress rules
  # Allow all local traffic
  ingress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = aws_vpc.this.cidr_block
    from_port  = 0
    to_port    = 0
  }

  # Allow HTTPS traffic from the internet
  ingress {
    protocol   = "6"
    rule_no    = 105
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 443
    to_port    = 443
  }

  # Allow HTTP traffic from the internet
  ingress {
    protocol   = "6"
    rule_no    = 110
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  # Allow the ephemeral ports from the internet
  ingress {
    protocol   = "6"
    rule_no    = 120
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  ingress {
    protocol   = "17"
    rule_no    = 125
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  # Egress rules
  # Allow all ports, protocols, and IPs outbound
  egress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  tags = {
    Name = "${var.vpc_name}-public-nacl"
  }

  depends_on = [aws_subnet.public]
}

The web subnet NACLs for this module.

resource "aws_network_acl" "web" {
  vpc_id     = aws_vpc.this.id
  subnet_ids = [aws_subnet.web[0].id, aws_subnet.web[1].id]

  # Ingress rules
  # Allow all local traffic
  ingress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = aws_vpc.this.cidr_block
    from_port  = 0
    to_port    = 0
  }

  # Allow HTTP web traffic from anywhere
  ingress {
    protocol   = 6
    rule_no    = 105
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  # Allow HTTPS web traffic from anywhere
  ingress {
    protocol   = 6
    rule_no    = 110
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 443
    to_port    = 443
  }

  # Allow the ephemeral ports from the internet
  ingress {
    protocol   = "6"
    rule_no    = 120
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  ingress {
    protocol   = "17"
    rule_no    = 125
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1025
    to_port    = 65534
  }

  # Egress rules
  # Allow all ports, protocols, and IPs outbound
  egress {
    protocol   = -1
    rule_no    = 100
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

  tags = {
    Name = "${var.vpc_name}-web-nacl"
  }
}

The App and Data subnet NACLs have been set to allow only local traffic. You may adjust these rules.

VPC Security Group

A default security group is created every time a new VPC is provisioned. Here I’ll just give it some tags and few generic rules.

# Modify the default security group
resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id

  dynamic "ingress" {
    for_each = var.default_security_group_ingress
    content {
      self        = lookup(ingress.value, "self", null)
      cidr_blocks = compact(split(",", lookup(ingress.value, "cidr_blocks", "")))
      description = lookup(ingress.value, "description", null)
      from_port   = lookup(ingress.value, "from_port", 0)
      to_port     = lookup(ingress.value, "to_port", 0)
      protocol    = lookup(ingress.value, "protocol", "-1")
    }
  }

  dynamic "egress" {
    for_each = var.default_security_group_egress
    content {
      self        = lookup(egress.value, "self", null)
      cidr_blocks = compact(split(",", lookup(egress.value, "cidr_blocks", "")))
      description = lookup(egress.value, "description", null)
      from_port   = lookup(egress.value, "from_port", 0)
      to_port     = lookup(egress.value, "to_port", 0)
      protocol    = lookup(egress.value, "protocol", "-1")
    }
  }

  tags = merge(
    {
      Name = format("%s-default-security-group", var.vpc_name)
    },
    var.additional_tags
  )
}

Now the values for this security group are passed as a variable like so. Be sure to change the ports and protocols to meet your needs.

variable "default_security_group_ingress" {
  description = "List of maps of ingress rules to set on the default security group"
  type        = list(map(string))
  default = [
    {
      cidr_blocks = "10.0.0.0/16"
      description = "Allow all from the local network."
      from_port   = 0
      protocol    = "-1"
      self        = false
      to_port     = 0
    },
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all HTTPS from the internet."
      from_port   = 443
      protocol    = "6"
      self        = false
      to_port     = 443
    },
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all HTTP from the internet."
      from_port   = 80
      protocol    = "6"
      self        = false
      to_port     = 80
    },
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all ephemeral ports from the internet."
      from_port   = 32768
      protocol    = "6"
      self        = false
      to_port     = 60999
    }
  ]
}

variable "default_security_group_egress" {
  description = "List of maps of egress rules to set on the default security group"
  type        = list(map(string))
  default = [
    {
      cidr_blocks = "0.0.0.0/0"
      description = "Allow all"
      from_port   = 0
      protocol    = "-1"
      self        = false
      to_port     = 0
    }
  ]
}

Here’s a link to the complete code so far in the dev branch. That’s it all for now! In the next post, we’ll add application load balancers, target groups, listeners, etc. For a more in-depth explanation of VPC resources checkout AWS technical documentation. Be sure to subscribe for more content like this!

Published by

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.