Skip to content

Terraform AWS VPC Tutorial - Public, Private, and Isolated Subnets

Create an AWS VPC using Terraform

In the previous section, we covered the basics of Terraform, how it works, and how to authenticate with AWS using different approaches, including IAM roles.

  • Let's go ahead and create a 0-terraform folder that we'll initially use to create VPC components.
0-terraform
  • Next we need to declare AWS Provider, in the previos session we created IAM role for the terraform, you can keep using that but to keep it simple and self contained I'll use default authentication mechanism for this lesson.
0-providers.tf
provider "aws" {
  region  = "us-east-2"
}

terraform {
  required_version = ">= 1.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
  • Next step is to create AWS VPC itself and enable DNS since it's commonly used by many compoentns including EKS and EFS and you will be requred to enable it anyway.
1-vpc.tf
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"

  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "dev-main"
  }
}
  • It's very common that you would need to download packages or interact with internet in some way, for that we need internet gateway. Even if you only will use private subnets you need this component.
2-igw.tf
resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "dev-igw"
  }
}

Create Public AWS Subnets

  • To make applications highly available, you need to create subnets in at least two different availability zones, which are separate data centers.
3-public-subnets.tf
resource "aws_subnet" "public_zone1" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.0.0/19"
  availability_zone       = "us-east-2a"
  map_public_ip_on_launch = true

  tags = {
    "Name"                           = "dev-public-us-east-2a"
    "kubernetes.io/role/elb"         = "1"
    "kubernetes.io/cluster/dev-demo" = "owned"
  }
}

resource "aws_subnet" "public_zone2" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.32.0/19"
  availability_zone       = "us-east-2b"
  map_public_ip_on_launch = true

  tags = {
    "Name"                           = "dev-public-us-east-2b"
    "kubernetes.io/role/elb"         = "1"
    "kubernetes.io/cluster/dev-demo" = "owned"
  }
}
  • Subnets by themselves only have local routes that allow applications to interact within the VPC. To allow applications to reach the Internet, you need to create a public route table and associate it with the subnets.
4-public-routes.tf
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "dev-public"
  }
}

resource "aws_route_table_association" "public_zone1" {
  subnet_id      = aws_subnet.public_zone1.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_zone2" {
  subnet_id      = aws_subnet.public_zone2.id
  route_table_id = aws_route_table.public.id
}

Create Private AWS Subnets

  • Next let's create private subnets which would not get public Ip addresses and will not be routable from the internet but you still be able to use NAT gateway to reach internet, like to download necesary packes or access remote REST API.
5-nat.tf
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "dev-nat"
  }
}

resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public_zone1.id

  tags = {
    Name = "dev-nat"
  }

  depends_on = [aws_internet_gateway.igw]
}
  • Next, create the subnets.
6-private-subnets.tf
resource "aws_subnet" "private_zone1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.64.0/19"
  availability_zone = "us-east-2a"

  tags = {
    "Name"                            = "dev-private-us-east-2a"
    "kubernetes.io/role/internal-elb" = "1"
    "kubernetes.io/cluster/dev-demo"  = "owned"
  }
}

resource "aws_subnet" "private_zone2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.96.0/19"
  availability_zone = "us-east-2b"

  tags = {
    "Name"                            = "dev-private-us-east-2b"
    "kubernetes.io/role/internal-elb" = "1"
    "kubernetes.io/cluster/dev-demo"  = "owned"
  }
}
  • Next, create a private route table with a default route to a NAT gateway and associate the private subnets with this route table.
7-private-routes.tf
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "dev-private"
  }
}

resource "aws_route_table_association" "private_zone1" {
  subnet_id      = aws_subnet.private_zone1.id
  route_table_id = aws_route_table.private.id
}

resource "aws_route_table_association" "private_zone2" {
  subnet_id      = aws_subnet.private_zone2.id
  route_table_id = aws_route_table.private.id
}

Create Isolated AWS Subnets

  • In some cases, you may want to completely isolate applications, such as databases, from the Internet. To do this, create isolated subnets and use only the local route provided by the VPC.
8-isolated-subnets.tf
resource "aws_subnet" "isolated_zone1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.128.0/19"
  availability_zone = "us-east-2a"

  tags = {
    "Name" = "dev-isolated-us-east-2a"
  }
}

resource "aws_subnet" "isolated_zone2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.160.0/19"
  availability_zone = "us-east-2b"

  tags = {
    "Name" = "dev-isolated-us-east-2b"
  }
}

Before we continue, let us destroy the infrastructure.

terraform destroy --auto-approve

Optimize Terraform Code

  • Let us copy and paste specific Terraform code and refactor it for portability.

  • First, create the 1-terraform folder in the project directory.

mkdir 1-terraform
  • Next, create a 0-locals.tf file in the 1-terraform folder to define locals. First, specify the AWS region for the infrastructure.
0-locals.tf
locals {
  region = "us-east-2"
}
  • Next, insert the AWS provider configuration into the 1-provider.tf file in the 1-terraform folder.
1-providers.tf
provider "aws" {
  region  = local.region
}

terraform {
  required_version = ">= 1.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
  • Let us add the vpc_cidr local variable to the 0-locals.tf file.
0-locals.tf
locals {
  region   = "us-east-2"
  vpc_cidr = "10.0.0.0/16"
  env      = "dev"
}
  • Copy the 1-vpc.tf file into the 1-terraform folder and replace the cidr_block value with the local.vpc_cidr variable.
2-vpc.tf
resource "aws_vpc" "main" {
  cidr_block = local.vpc_cidr

  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "${local.env}-main"
  }
}
  • Next, recreate the VPC using the Terraform configuration in the 1-terraform folder. Run terraform apply to provision the resources.
terraform init
terraform apply
  • Add local variables for the public subnets.

0-locals.tf
locals {
  ...
  azs            = ["us-east-2a", "us-east-2b"]
  public_subnets = ["10.0.0.0/19", "10.0.32.0/19"]
}
- Copy the 2-igw.tf file into the 1-terraform folder and replace the Name tag with the local.env variable.

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${local.env}-igw"
  }
}
  • Copy the 3-public-subnets.tf file into the 1-terraform folder and refactor it to use a count
4-public-subnets.tf
resource "aws_subnet" "public" {
  count = length(local.public_subnets)

  vpc_id                  = aws_vpc.main.id
  cidr_block              = local.public_subnets[count.index]
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true

  tags = {
    "Name"                           = "${local.env}-public-${local.azs[count.index]}"
    "kubernetes.io/role/elb"         = "1"
    "kubernetes.io/cluster/dev-demo" = "owned"
  }
}
  • Copy the 4-public-routes.tf file into the 1-terraform folder.
5-public-routes.tf
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "${local.env}-public"
  }
}

resource "aws_route_table_association" "public" {
  count = length(local.public_subnets)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}
  • Let us verify that public subnets can be dynamically created using the count variable in the 3-public-subnets.tf file.
terraform apply
  • Copy the 5-nat.tf file into the 1-terraform folder.
6-nat.tf
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "${local.env}-nat"
  }
}

resource "aws_nat_gateway" "nat" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${local.env}-nat"
  }

  depends_on = [aws_internet_gateway.igw]
}
  • Add local variables for private subnets to the 0-locals.tf file in the 1-terraform folder.
0-locals.tf
locals {
  ...
  private_subnets = {

    public_1 = {
      cidr = "10.0.64.0/19"
      az   = "us-east-2a"
    }

    public_2 = {
      cidr = "10.0.96.0/19"
      az   = "us-east-2b"
    }

  }
}
  • Copy the 6-private-subnets.tf file into the 1-terraform folder.
7-private-subnets.tf
resource "aws_subnet" "private" {
  for_each = local.private_subnets

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = {
    "Name"                            = "${local.env}-private-${each.value.az}"
    "kubernetes.io/role/internal-elb" = "1"
    "kubernetes.io/cluster/dev-demo"  = "owned"
  }
}
  • Copy the 7-private-routes.tf file into the 1-terraform.
8-private-routes.tf
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

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

  tags = {
    Name = "${local.env}-private"
  }
}

resource "aws_route_table_association" "private" {
  for_each = local.private_subnets

  subnet_id      = aws_subnet.private[each.key].id
  route_table_id = aws_route_table.private.id
}
  • Verify that private subnets can be created using the configuration in the 6-private-subnets.tf file.
terraform apply
  • Use the built-in cidrsubnet function to divide CIDR ranges for private subnets in the 6-private-subnets.tf.
0-locals.tf
locals {
  ...
  private_subnets = {

    public_1 = {
      cidr = cidrsubnet(local.vpc_cidr, 3, 2)
      az   = "us-east-2a"
    }

    public_2 = {
      cidr = cidrsubnet(local.vpc_cidr, 3, 3)
      az   = "us-east-2b"
    }

  }
}
  • Verify that no changes are detected by running terraform plan in the 1-terraform folder.
terraform plan
  • Create a conditional variable in variables.tf to enable on-demand creation of isolated subnets in the 1-terraform folder.
locals {
  ...
  create_isolated_subnets = false
}
  • Copy the 8-isolated-subnets.tf file into the 1-terraform folder.
9-isolated-subnets.tf
resource "aws_subnet" "isolated_zone1" {
  count = local.create_isolated_subnets ? 1 : 0

  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.128.0/19"
  availability_zone = "us-east-2a"

  tags = {
    "Name" = "dev-isolated-us-east-2a"
  }
}

resource "aws_subnet" "isolated_zone2" {
  count = local.create_isolated_subnets ? 1 : 0

  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.160.0/19"
  availability_zone = "us-east-2b"

  tags = {
    "Name" = "dev-isolated-us-east-2b"
  }
}
  • Verify that isolated subnets can be created conditionally by setting the conditional variable to true and false.
terraform apply
  • Create a security group configuration in a new 10-sg.tf file.
10-sg.tf
resource "aws_security_group" "web" {
  name   = "web"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["63.10.10.10/32"]
  }

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
  • Create a security group in AWS.
terraform apply
  • Add a local variable for ingress rules to the 0-locals.tf file.
0-locals.tf
locals {
  ...
  ingress_rules = {
    22 = "63.10.10.10/32"
    80 = "0.0.0.0/0"
  }
}
  • Update the 10-sg.tf file to dynamically generate ingress rules using the local variable.
10-sg.tf
resource "aws_security_group" "web" {
  name   = "web"
  vpc_id = aws_vpc.main.id

  dynamic "ingress" {
    for_each = local.ingress_rules

    content {
      from_port   = ingress.key
      to_port     = ingress.key
      protocol    = "tcp"
      cidr_blocks = [ingress.value]
    }
  }
}
  • Verify that no changes are required by running terraform plan in the 1-terraform folder.
terraform plan
  • Clean Up.
terraform destroy --auto-approve