Three-Tier AWS Network with Terraform

Build a reusable Terraform module for a dual-stack three-tier AWS VPC.

Introduction

30 Second Summary

Every web application you use sits on a network that separates its public-facing servers from its sensitive databases. This separation is what keeps your data safe when one layer gets compromised.

In this project, you will build a production-grade three-tier AWS network using Terraform modules. You'll write reusable infrastructure code that creates an entire networking stack with a single command.

What You'll Build

When you finish, you can run a single terraform apply and watch an entire dual-stack network appear in AWS, complete with six subnets across two availability zones, outbound-only internet access for private tiers, and security groups that enforce "web talks to app, app talks to data, nothing else gets through."

By the end of this project, you'll have:

  • A reusable VPC module you wrote from scratch that provisions a dual-stack (IPv4 + IPv6) three-tier network across 2 availability zones with a single module call.
  • A security group module that chains access tier-by-tier: internet to web, web to app, app to data.
  • Two deployed environments (dev and prod) from the same module code with different CIDRs, proving your modules are fully reusable building blocks.
  • Secret Mission: Add a VPC Flow Logs module to capture and monitor network traffic.

Are there any prerequisites?

You'll need an AWS account with credentials configured, Terraform installed, and a code editor (VS Code or Cursor). Step 1 walks you through verifying your setup.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the goal before any hands-on work begins.

Set Up the Project Structure

Terraform uses a specific directory layout to locate modules. Without this structure, a module call in your root configuration fails because Terraform cannot find the source path.

In this step you will create the project skeleton with two child module directories, write the provider configuration, and define all the input variables your modules will consume. By the end, Terraform will be initialized and ready to build infrastructure.

In this step, get ready to:
  • Create the project directory structure with root files and two module directories.
  • Write the root provider configuration and input variable definitions.
  • Initialize the project and download the AWS provider.
Create the project directory structure

A Terraform module is just a folder containing .tf files. Your project needs a root module (the top-level folder) and two child module directories for the VPC and security groups.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder, module directories, and placeholder files by running these commands:
mkdir -p terraform-three-tier/modules/vpc
mkdir -p terraform-three-tier/modules/security-groups
cd terraform-three-tier
touch main.tf outputs.tf terraform.tf variables.tf
touch modules/vpc/main.tf modules/vpc/variables.tf modules/vpc/outputs.tf
touch modules/security-groups/main.tf modules/security-groups/variables.tf modules/security-groups/outputs.tf

What does this structure do?

The root folder holds your provider config and module calls. Each subfolder under modules/ is a self-contained module with its own inputs (variables.tf), resources (main.tf), and outputs (outputs.tf).

Terraform resolves module sources relative to the calling file, so keeping modules inside the project folder means your source = "./modules/vpc" paths will just work.

  • Confirm the structure was created correctly by running:
ls modules/vpc modules/security-groups

You should see main.tf, outputs.tf, and variables.tf listed under both directories.

Don't see the expected files?

  • Make sure you ran the mkdir -p commands first. The -p flag creates parent directories automatically.
  • Check your current directory with pwd. You should be inside terraform-three-tier on your Desktop.
  • help me debug my Terraform project directory structure
Write the Terraform provider and variable definitions

The root terraform.tf file declares which Terraform version and which providers your project needs. The variables.tf file defines every input your modules will receive.

  • Open terraform.tf in your code editor and paste the following provider configuration:
terraform {
  required_version = ">= 1.15.0"

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

provider "aws" {
  region = var.aws_region
}

What does this code do?

  • required_version = ">= 1.15.0" ensures anyone running this project uses Terraform 1.15.0 or newer.
  • The hashicorp/aws provider with ~> 6.0 allows any 6.x release but blocks a jump to 7.0 which could introduce breaking changes.
  • The provider block references var.aws_region so you can deploy to any region without editing this file.
  • Save terraform.tf.
  • Open variables.tf and paste the general project variables:
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Environment name (e.g., dev, prod)"
  type        = string
  default     = "dev"
}

variable "vpc_cidr" {
  description = "IPv4 CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "List of availability zones to use"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b"]
}

What do these variables control?

  • aws_region sets where your infrastructure deploys. The default is us-east-1.
  • environment tags every resource so you can tell dev from prod at a glance.
  • vpc_cidr defines the overall IP address space (65,536 addresses in a /16).
  • availability_zones spreads subnets across two physical data centres for high availability.
  • Save variables.tf.
Initialize Terraform and complete the variables

With your provider configuration saved, Terraform can now download the AWS provider plugin. Initializing early confirms your terraform.tf is correct before you write more code.

  • Before you run this, what do you expect Terraform to download based on your required_providers block?
  • Initialize the project by running:
terraform init

You should see "Terraform has been successfully initialized!" and a line confirming it installed the hashicorp/aws provider.

Seeing a provider version error?

  • Make sure your terraform.tf has version = "~> 6.0" (not a specific patch version). The ~> operator allows minor version updates.
  • Check you are running Terraform 1.15.0 or newer with terraform version.
  • help me fix my terraform init error

Now finish the variables file with the subnet and port definitions.

  • Open variables.tf and add the following below the availability_zones variable:
variable "web_subnet_cidrs" {
  description = "IPv4 CIDR blocks for web (public) subnets"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "app_subnet_cidrs" {
  description = "IPv4 CIDR blocks for app (private) subnets"
  type        = list(string)
  default     = ["10.0.11.0/24", "10.0.12.0/24"]
}

variable "data_subnet_cidrs" {
  description = "IPv4 CIDR blocks for data (private) subnets"
  type        = list(string)
  default     = ["10.0.21.0/24", "10.0.22.0/24"]
}

variable "app_port" {
  description = "Port for the application tier"
  type        = number
  default     = 8080
}

variable "db_port" {
  description = "Port for the database tier"
  type        = number
  default     = 5432
}

What do these variables control?

  • The three _subnet_cidrs variables each define two CIDR blocks (one per availability zone) for the web, app, and data tiers.
  • app_port (8080) is the port the application tier listens on. Security group rules will reference this.
  • db_port (5432) is the default PostgreSQL port. The data tier security group will only allow traffic on this port from the app tier.
  • Save variables.tf.
  • Verify that Terraform can parse all your variable definitions by running:
terraform validate

You should see "Success! The configuration is valid." confirming both files are syntactically correct.

Seeing a syntax error?

  • Check for missing closing braces } in variables.tf. Each variable block must open and close with curly braces.
  • Make sure list defaults use square brackets and quoted strings: ["10.0.1.0/24", "10.0.2.0/24"].
  • help me fix my Terraform variables syntax error

✔️ Awesome, I've got everything!

Great. Make sure you have saved both terraform.tf and variables.tf.

ⓧ I'd like to double check the full code

Your variables.tf was built across two chunks. Scroll up and confirm you have these sections in order:

  • Chunk 1: aws_region, environment, vpc_cidr, availability_zones (4 variable blocks).
  • Chunk 2: web_subnet_cidrs, app_subnet_cidrs, data_subnet_cidrs, app_port, db_port (5 variable blocks).

You should have 9 variable blocks total. Your terraform.tf is a single 13-line file:

terraform {
  required_version = ">= 1.15.0"

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

provider "aws" {
  region = var.aws_region
}

Your project skeleton is in place and Terraform knows where to find everything. Next up, you will build the VPC module that creates the full three-tier subnet layout.

Build and Deploy the VPC Module

Your project structure is ready, but every file inside modules/vpc/ is still empty. The VPC module is the networking foundation that every other resource in your project depends on.

In this step you will write the full VPC module from scratch, deploy it, and deliberately leave one gap: the private subnets will have no outbound internet route. You will see that gap with your own eyes in the AWS console, and that sets up the problem the next step solves.

In this step, get ready to:
  • Write the VPC module with dual-stack subnets, an internet gateway, and route tables.
  • Define the module's input variables and output values.
  • Wire up the root module, deploy to AWS, and confirm the private route table has no internet route.
Write the VPC and internet gateway

Every AWS network starts with a VPC and an internet gateway. The VPC defines your private address space, and the internet gateway gives public subnets a path to the outside world.

  • Open modules/vpc/main.tf in your editor and add the VPC and internet gateway resources:
resource "aws_vpc" "main" {
  cidr_block                       = var.vpc_cidr
  assign_generated_ipv6_cidr_block = true
  enable_dns_support               = true
  enable_dns_hostnames             = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

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

  tags = {
    Name        = "${var.environment}-igw"
    Environment = var.environment
  }
}

What does this code do?

assign_generated_ipv6_cidr_block = true requests an Amazon-provided IPv6 /56 block for your VPC, giving you dual-stack networking (both IPv4 and IPv6).

enable_dns_support and enable_dns_hostnames allow instances in the VPC to resolve public DNS names and receive their own DNS hostnames.

Create the web (public) subnets

The web tier is your public-facing layer. These subnets automatically assign public IPv4 and IPv6 addresses to any instance launched in them.

  • Add the web subnet resource below the internet gateway in modules/vpc/main.tf:
resource "aws_subnet" "web" {
  count = length(var.web_subnet_cidrs)

  vpc_id                          = aws_vpc.main.id
  cidr_block                      = var.web_subnet_cidrs[count.index]
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, count.index)
  availability_zone               = var.availability_zones[count.index]
  map_public_ip_on_launch         = true
  assign_ipv6_address_on_creation = true

  tags = {
    Name        = "${var.environment}-web-${var.availability_zones[count.index]}"
    Environment = var.environment
    Tier        = "web"
  }
}

What does cidrsubnet do here?

AWS gives your VPC a /56 IPv6 block. Each subnet must be exactly /64. The expression cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, count.index) adds 8 bits to the /56 prefix (56 + 8 = 64) and uses count.index (0 or 1) to pick the specific /64 slice.

map_public_ip_on_launch = true makes any EC2 instance launched here automatically get a public IPv4 address. This is what makes the web tier "public."

Create the app and data (private) subnets

The app and data tiers are private. They get IPv6 addresses but no public IPv4 addresses, so they cannot be reached directly from the internet.

  • Add the app and data subnet resources below the web subnets in modules/vpc/main.tf:
resource "aws_subnet" "app" {
  count = length(var.app_subnet_cidrs)

  vpc_id                          = aws_vpc.main.id
  cidr_block                      = var.app_subnet_cidrs[count.index]
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, count.index + 2)
  availability_zone               = var.availability_zones[count.index]
  assign_ipv6_address_on_creation = true

  tags = {
    Name        = "${var.environment}-app-${var.availability_zones[count.index]}"
    Environment = var.environment
    Tier        = "app"
  }
}

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

  vpc_id                          = aws_vpc.main.id
  cidr_block                      = var.data_subnet_cidrs[count.index]
  ipv6_cidr_block                 = cidrsubnet(aws_vpc.main.ipv6_cidr_block, 8, count.index + 4)
  availability_zone               = var.availability_zones[count.index]
  assign_ipv6_address_on_creation = true

  tags = {
    Name        = "${var.environment}-data-${var.availability_zones[count.index]}"
    Environment = var.environment
    Tier        = "data"
  }
}

Why different cidrsubnet index offsets?

Web subnets use index 0-1, app subnets use index 2-3 (count.index + 2), and data subnets use index 4-5 (count.index + 4). Each offset picks a unique /64 slice from the VPC's /56 block so no two subnets overlap.

Create the route tables

Route tables control where traffic goes. The public route table sends internet-bound traffic to the internet gateway. The private route table gets no internet routes for now.

  • Add the public and private route tables below the data subnets in modules/vpc/main.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.main.id
  }

  route {
    ipv6_cidr_block = "::/0"
    gateway_id      = aws_internet_gateway.main.id
  }

  tags = {
    Name        = "${var.environment}-public-rt"
    Environment = var.environment
  }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.environment}-private-rt"
    Environment = var.environment
  }
}

Why is the private route table empty?

This is intentional. The private route table has no route to 0.0.0.0/0 or ::/0, so any instance in a private subnet has zero outbound internet access right now. You will see this gap after deploying and fix it in the next step.

Add the route table associations
  • Add the route table association resources at the bottom of modules/vpc/main.tf:
resource "aws_route_table_association" "web" {
  count = length(var.web_subnet_cidrs)

  subnet_id      = aws_subnet.web[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "app" {
  count = length(var.app_subnet_cidrs)

  subnet_id      = aws_subnet.app[count.index].id
  route_table_id = aws_route_table.private.id
}

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

  subnet_id      = aws_subnet.data[count.index].id
  route_table_id = aws_route_table.private.id
}

What does this code do?

Route table associations bind each subnet to a specific route table. Web subnets are associated with the public route table (internet access via the IGW). App and data subnets are associated with the private route table (no internet access yet).

  • Save modules/vpc/main.tf.

✔️ Awesome, I've got everything!

Great. Confirm you have saved the file and the five resource groups are present in order: VPC, IGW, three subnet types, two route tables, three associations.

ⓧ I'd like to double check the full code

Scroll through modules/vpc/main.tf and confirm the following sections appear in this order:

  • aws_vpc "main" with assign_generated_ipv6_cidr_block = true.
  • aws_internet_gateway "main".
  • aws_subnet "web" with map_public_ip_on_launch = true and cidrsubnet(..., 8, count.index).
  • aws_subnet "app" with cidrsubnet(..., 8, count.index + 2).
  • aws_subnet "data" with cidrsubnet(..., 8, count.index + 4).
  • aws_route_table "public" with two routes (0.0.0.0/0 and ::/0).
  • aws_route_table "private" with NO routes.
  • Three aws_route_table_association resources (web, app, data).
Define the module inputs and outputs

Terraform modules communicate with the outside world through input variables and output values. The variables file declares what the caller must provide. The outputs file exposes what the module created.

  • Open modules/vpc/variables.tf and add the input variable declarations:
variable "environment" {
  description = "Environment name"
  type        = string
}

variable "vpc_cidr" {
  description = "IPv4 CIDR block for the VPC"
  type        = string
}

variable "availability_zones" {
  description = "List of availability zones"
  type        = list(string)
}

variable "web_subnet_cidrs" {
  description = "IPv4 CIDR blocks for web subnets"
  type        = list(string)
}

variable "app_subnet_cidrs" {
  description = "IPv4 CIDR blocks for app subnets"
  type        = list(string)
}

variable "data_subnet_cidrs" {
  description = "IPv4 CIDR blocks for data subnets"
  type        = list(string)
}

What does this code do?

Each variable block defines one input the module expects from its caller. The type constraint tells Terraform what shape of data to accept (a plain string, or a list of strings for multiple subnets/AZs).

  • Save modules/vpc/variables.tf.
  • Open modules/vpc/outputs.tf and add the output declarations:
output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "vpc_cidr_block" {
  description = "The IPv4 CIDR block of the VPC"
  value       = aws_vpc.main.cidr_block
}

output "vpc_ipv6_cidr_block" {
  description = "The IPv6 CIDR block of the VPC"
  value       = aws_vpc.main.ipv6_cidr_block
}

output "web_subnet_ids" {
  description = "IDs of web (public) subnets"
  value       = aws_subnet.web[*].id
}

output "app_subnet_ids" {
  description = "IDs of app (private) subnets"
  value       = aws_subnet.app[*].id
}

output "data_subnet_ids" {
  description = "IDs of data (private) subnets"
  value       = aws_subnet.data[*].id
}

output "internet_gateway_id" {
  description = "The ID of the internet gateway"
  value       = aws_internet_gateway.main.id
}

What does this code do?

Outputs expose resource attributes so the root module (and other modules) can reference them. The [*].id splat expression collects the IDs of all subnet instances created by count into a list.

  • Save modules/vpc/outputs.tf.

Seeing a syntax error in your editor?

Check that every opening { has a matching } and that string interpolations use the ${...} pattern. Also confirm variables.tf has no default values. Module variables intentionally omit defaults so the caller is forced to provide them.

Still stuck? Help me fix a syntax error in my Terraform VPC module files.

Wire up the root module and deploy

The VPC module is complete, but Terraform does not know about it yet. You need a module block in the root configuration to call it, plus outputs to surface the module's values.

  • Open the root main.tf (in your project root, not inside modules/) and add the module call:
module "vpc" {
  source = "./modules/vpc"

  environment        = var.environment
  vpc_cidr           = var.vpc_cidr
  availability_zones = var.availability_zones
  web_subnet_cidrs   = var.web_subnet_cidrs
  app_subnet_cidrs   = var.app_subnet_cidrs
  data_subnet_cidrs  = var.data_subnet_cidrs
}

What does this code do?

The module block tells Terraform to load the VPC module from the local ./modules/vpc path. Each argument passes a root-level variable through to the module's matching input variable. This is the "dependency inversion" pattern: the module declares what it needs, and the caller provides it.

  • Save main.tf.
  • Open the root outputs.tf and add these outputs that surface the module's values:
output "vpc_id" {
  description = "The ID of the VPC"
  value       = module.vpc.vpc_id
}

output "vpc_ipv6_cidr_block" {
  description = "The IPv6 CIDR block of the VPC"
  value       = module.vpc.vpc_ipv6_cidr_block
}

output "web_subnet_ids" {
  description = "IDs of the web (public) subnets"
  value       = module.vpc.web_subnet_ids
}

output "app_subnet_ids" {
  description = "IDs of the app (private) subnets"
  value       = module.vpc.app_subnet_ids
}

output "data_subnet_ids" {
  description = "IDs of the data (private) subnets"
  value       = module.vpc.data_subnet_ids
}
  • Save outputs.tf.

Since you added a new module block, Terraform needs to re-initialize to discover it.

  • Re-initialize the project by running:
terraform init

You should see Terraform detect the local module:

Initializing modules...
- vpc in modules/vpc

Initializing the backend...

Terraform has been successfully initialized!

Seeing an error during init?

If Terraform reports missing variables or a reference error, double-check that modules/vpc/variables.tf declares every variable used in modules/vpc/main.tf (environment, vpc_cidr, availability_zones, web_subnet_cidrs, app_subnet_cidrs, data_subnet_cidrs).

Help me debug a terraform init error for my local VPC module.

Now create the variable values file that tells Terraform what CIDR ranges and AZs to use for the dev environment.

  • Create a new file called dev.tfvars in your project root and add:
environment        = "dev"
aws_region         = "us-east-1"
vpc_cidr           = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
web_subnet_cidrs   = ["10.0.1.0/24", "10.0.2.0/24"]
app_subnet_cidrs   = ["10.0.11.0/24", "10.0.12.0/24"]
data_subnet_cidrs  = ["10.0.21.0/24", "10.0.22.0/24"]
app_port           = 8080
db_port            = 5432

Why a separate .tfvars file?

A .tfvars file lets you define environment-specific values without changing the module code. Later, you will create a prod.tfvars with different CIDRs to deploy an identical but isolated prod network from the same modules.

  • Save dev.tfvars.

Before you run the apply, predict what you think will happen. Will both the public and private subnets have internet access?

  • Deploy the VPC module by running:
terraform apply -var-file=dev.tfvars
  • When Terraform shows the plan, review it and type yes to confirm.

Terraform creates 13 resources: 1 VPC, 1 internet gateway, 6 subnets, 2 route tables, and 4 route table associations (note that the 2 web associations share the public route table).

Verify the designed gap in the AWS console
  • Open the AWS Management Console and navigate to VPC in the Services menu.
  • Click Route tables in the left sidebar.
  • Find the route table named dev-private-rt and click its ID to open it.
  • Click the Routes tab.

You should see only one route: the local route (10.0.0.0/16 targeting local). There is no 0.0.0.0/0 entry pointing to a NAT gateway, and no ::/0 entry pointing to an egress-only internet gateway.

This means any instance launched in an app or data subnet cannot reach the internet at all. That is the gap you will close in the next step.

Your VPC module is deployed and the three-tier subnet layout is live. Next up, you will add a NAT gateway and egress-only internet gateway to give those private subnets a way out.

Add NAT Gateway for Private Subnet Connectivity

In the previous step, you saw that the private route table has only a local route. Any instance placed in your app or data subnets is completely cut off from the internet.

To fix this, you need two outbound gateways. A NAT gateway handles IPv4 outbound traffic, and an egress-only internet gateway handles IPv6. Together, they let private resources make outbound connections (downloading packages, calling APIs) without accepting inbound connections from the internet.

In this step, get ready to:
  • Add a NAT gateway, Elastic IP, and egress-only internet gateway to the VPC module.
  • Update the private route table to route outbound traffic through these gateways.
  • Apply the changes and verify connectivity in the AWS console.
Add outbound gateway resources to the VPC module

IPv6 does not use NAT. Instead, AWS provides a dedicated resource called an egress-only internet gateway that allows outbound IPv6 traffic while blocking all inbound.

  • In modules/vpc/main.tf, find the closing brace of the aws_internet_gateway resource.
  • Add the following egress-only internet gateway resource directly below it:
resource "aws_egress_only_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.environment}-eigw"
    Environment = var.environment
  }
}

What does this resource do?

The egress-only internet gateway is IPv6-only. It lets instances in private subnets initiate outbound IPv6 connections while preventing anyone on the internet from initiating inbound connections to those instances.

Now you need the IPv4 equivalent. A NAT gateway requires an Elastic IP address to serve as its stable public IPv4 address.

  • Scroll down past all six subnet resources (web, app, data).
  • After the closing brace of the last aws_subnet "data" resource, add the Elastic IP and NAT gateway:
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name        = "${var.environment}-nat-eip"
    Environment = var.environment
  }
}

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

  tags = {
    Name        = "${var.environment}-nat"
    Environment = var.environment
  }

  depends_on = [aws_internet_gateway.main]
}

What does this code do?

  • aws_eip "nat" allocates a static public IPv4 address in the VPC domain. This becomes the NAT gateway's public face.
  • aws_nat_gateway "main" places the NAT gateway in the first web (public) subnet so it has internet access through the internet gateway.
  • depends_on ensures Terraform creates the internet gateway first. Without a working IGW, the NAT gateway cannot route traffic to the internet.
  • Save modules/vpc/main.tf.
  • Preview what Terraform will do by running:
terraform plan -var-file=dev.tfvars

You should see Terraform planning to add 3 new resources (the EIP, NAT gateway, and egress-only IGW) with no changes to your existing resources yet.

Seeing an error about invalid references?

  • Make sure aws_eip.nat is defined before aws_nat_gateway.main in the file. The NAT gateway references the EIP's ID.
  • Check that you placed the egress-only IGW inside the modules/vpc/main.tf file, not the root main.tf.

help me fix a Terraform plan error with my NAT gateway or egress-only IGW resources

Add routes to the private route table

The gateway resources exist, but the private route table still does not know about them. You need to add route blocks that point outbound traffic to these gateways.

  • In modules/vpc/main.tf, scroll down to the aws_route_table "private" resource.
  • Find this block:
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.environment}-private-rt"
    Environment = var.environment
  }
}
  • Replace the entire aws_route_table "private" block with this updated version that includes outbound routes:
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.main.id
  }

  route {
    ipv6_cidr_block        = "::/0"
    egress_only_gateway_id = aws_egress_only_internet_gateway.main.id
  }

  tags = {
    Name        = "${var.environment}-private-rt"
    Environment = var.environment
  }
}

What do these routes do?

  • The first route sends all IPv4 traffic destined for the internet (0.0.0.0/0) through the NAT gateway. The NAT gateway translates private IPs to its public Elastic IP.
  • The second route sends all IPv6 internet traffic (::/0) through the egress-only IGW. IPv6 does not need address translation because addresses are globally unique.

Finally, expose the NAT gateway's ID as a module output so the root module can reference it if needed.

  • Open modules/vpc/outputs.tf and add this output block at the end of the file, after the existing internet_gateway_id output:
output "nat_gateway_id" {
  description = "The ID of the NAT gateway"
  value       = aws_nat_gateway.main.id
}
  • Save both modules/vpc/main.tf and modules/vpc/outputs.tf.

Seeing a cycle error in your plan?

  • Make sure the private route table references aws_nat_gateway.main.id (not the EIP or the internet gateway). The NAT gateway is the hop for private traffic.
  • Check that egress_only_gateway_id points to aws_egress_only_internet_gateway.main.id. A typo here causes an invalid reference error.

help me debug a cycle or reference error in my Terraform private route table

Apply and verify the private route table has outbound routes

Before you run this, what do you expect Terraform to do? Think about how many new resources it needs to create versus how many existing resources it needs to update.

  • Apply the changes by running:
terraform apply -var-file=dev.tfvars

Terraform creates 3 new resources (the Elastic IP, NAT gateway, and egress-only internet gateway) and updates 1 resource (the private route table now includes the two outbound routes). Type yes when prompted to confirm.

Why does the NAT gateway take a minute or two?

AWS needs to provision physical network infrastructure behind the scenes. The NAT gateway transitions from a "pending" state to "available" before Terraform marks it as created. This is normal.

  • In the AWS Management Console, navigate to VPC then Route tables.
  • Find the route table named dev-private-rt and click on it.
  • Select the Routes tab.

You should see three routes: the local route (which was already there), 0.0.0.0/0 pointing to the NAT gateway, and ::/0 pointing to the egress-only internet gateway.

Don't see the new routes?

  • Make sure terraform apply completed without errors. Check the terminal output for any failures.
  • Confirm you are looking at the dev-private-rt route table, not the dev-public-rt one.
  • Verify your AWS console region matches us-east-1 (the region in your dev.tfvars).

help me find my private route table routes in the AWS console after running terraform apply

✔️ Awesome, I've got everything!

Great. Double check you have saved both modules/vpc/main.tf and modules/vpc/outputs.tf.

ⓧ I'd like to double check the full code

Scroll up and confirm your modules/vpc/main.tf contains these sections in order:

  • aws_vpc "main" with IPv6 and DNS settings.
  • aws_internet_gateway "main" attached to the VPC.
  • aws_egress_only_internet_gateway "main" attached to the VPC.
  • aws_subnet "web", aws_subnet "app", and aws_subnet "data" (each with count).
  • aws_eip "nat" with domain = "vpc".
  • aws_nat_gateway "main" in aws_subnet.web[0] with depends_on.
  • aws_route_table "public" with IGW routes.
  • aws_route_table "private" with NAT gateway and egress-only IGW routes.
  • All route table associations (web, app, data).

Your modules/vpc/outputs.tf should have 8 outputs: vpc_id, vpc_cidr_block, vpc_ipv6_cidr_block, web_subnet_ids, app_subnet_ids, data_subnet_ids, nat_gateway_id, and internet_gateway_id.

Your private subnets now have a path to the internet for outbound traffic. Next up, you will build security groups that control which tiers can talk to each other.

Build the Security Group Module

Your VPC has subnets that control where traffic can route. But subnets alone do not control what traffic is allowed to flow between resources.

Without tier-aware security groups, any resource in the VPC could talk to any other resource on any port. You need to enforce the three-tier pattern: only the internet reaches web, only web reaches app, only app reaches data. In this step, you will build a composable security group module that chains access between tiers using security group references instead of CIDR blocks.

In this step, get ready to:
  • Write three security groups (web, app, data) with ingress rules that enforce tier-to-tier isolation.
  • Add egress rules and define the module interface (variables and outputs).
  • Connect the security group module to the root configuration and deploy.
Write the security groups and ingress rules

Security groups act as virtual firewalls attached to individual resources. You will create one per tier and use referenced_security_group_id to allow traffic only from the tier above.

Why security group references instead of CIDR blocks?

Referencing another security group means "allow traffic from anything wearing that badge." If you add or remove instances in the web tier, the app tier automatically trusts or distrusts them. No manual CIDR updates required.

  • Open modules/security-groups/main.tf in your editor and add the web tier security group with its HTTP ingress rules:
resource "aws_security_group" "web" {
  name_prefix = "${var.environment}-web-"
  description = "Security group for web tier"
  vpc_id      = var.vpc_id

  tags = {
    Name        = "${var.environment}-web-sg"
    Environment = var.environment
    Tier        = "web"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "web_http" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  description       = "HTTP from internet"
}

resource "aws_vpc_security_group_ingress_rule" "web_http_ipv6" {
  security_group_id = aws_security_group.web.id
  cidr_ipv6         = "::/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  description       = "HTTP from internet IPv6"
}

What does this code do?

  • aws_security_group.web creates the web tier firewall attached to your VPC. The name_prefix avoids name collisions across environments, and create_before_destroy ensures zero-downtime replacement if the SG ever needs recreating.
  • The two ingress rules open port 80 (HTTP) from the entire internet for both IPv4 (0.0.0.0/0) and IPv6 (::/0). These are standalone aws_vpc_security_group_ingress_rule resources, not inline blocks.
  • Below the HTTP rules, add the HTTPS ingress rules to complete the web tier:
resource "aws_vpc_security_group_ingress_rule" "web_https" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
  description       = "HTTPS from internet"
}

resource "aws_vpc_security_group_ingress_rule" "web_https_ipv6" {
  security_group_id = aws_security_group.web.id
  cidr_ipv6         = "::/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
  description       = "HTTPS from internet IPv6"
}

What does this code do?

These rules mirror the HTTP rules but for port 443 (HTTPS). Together, the four ingress rules let the public internet reach the web tier on both standard web ports using either protocol version.

  • Save modules/security-groups/main.tf.
  • Verify your syntax is valid by running:
terraform fmt modules/security-groups/main.tf

If the file needed reformatting, you will see the filename printed. If nothing prints, the file is already correctly formatted.

Seeing an error from terraform fmt?

  • Check for unmatched curly braces or missing closing } on any resource block.
  • Make sure every string value is wrapped in double quotes.
  • Confirm you saved the file before running the command.

help me fix a terraform fmt error in my security groups module

  • Now add the app tier security group and its ingress rule below your web rules in modules/security-groups/main.tf:
resource "aws_security_group" "app" {
  name_prefix = "${var.environment}-app-"
  description = "Security group for app tier"
  vpc_id      = var.vpc_id

  tags = {
    Name        = "${var.environment}-app-sg"
    Environment = var.environment
    Tier        = "app"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "app_from_web" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.web.id
  from_port                    = var.app_port
  to_port                      = var.app_port
  ip_protocol                  = "tcp"
  description                  = "App port from web tier"
}

What does this code do?

The app security group only allows inbound traffic on var.app_port (8080) from resources that belong to the web security group. Notice there is no CIDR here. The referenced_security_group_id attribute says "trust anything wearing the web SG badge."

  • Below the app rules, add the data tier security group and its ingress rule:
resource "aws_security_group" "data" {
  name_prefix = "${var.environment}-data-"
  description = "Security group for data tier"
  vpc_id      = var.vpc_id

  tags = {
    Name        = "${var.environment}-data-sg"
    Environment = var.environment
    Tier        = "data"
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_vpc_security_group_ingress_rule" "data_from_app" {
  security_group_id            = aws_security_group.data.id
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = var.db_port
  to_port                      = var.db_port
  ip_protocol                  = "tcp"
  description                  = "Database port from app tier"
}

What does this code do?

The data tier only accepts traffic on var.db_port (5432) from the app security group. This completes the chain: internet reaches web, web reaches app, app reaches data. Nothing else gets through.

  • Save modules/security-groups/main.tf and verify the syntax again:
terraform fmt modules/security-groups/main.tf

No output means your formatting is clean.

Add egress rules and define the module interface

Every security group needs outbound rules. All three tiers need unrestricted egress so resources can reach package repositories, AWS APIs, and external services.

  • Below the data ingress rule in modules/security-groups/main.tf, add egress rules for the web and app tiers:
resource "aws_vpc_security_group_egress_rule" "web_all_ipv4" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
  description       = "All outbound IPv4"
}

resource "aws_vpc_security_group_egress_rule" "web_all_ipv6" {
  security_group_id = aws_security_group.web.id
  cidr_ipv6         = "::/0"
  ip_protocol       = "-1"
  description       = "All outbound IPv6"
}

resource "aws_vpc_security_group_egress_rule" "app_all_ipv4" {
  security_group_id = aws_security_group.app.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
  description       = "All outbound IPv4"
}

resource "aws_vpc_security_group_egress_rule" "app_all_ipv6" {
  security_group_id = aws_security_group.app.id
  cidr_ipv6         = "::/0"
  ip_protocol       = "-1"
  description       = "All outbound IPv6"
}

What does this code do?

Setting ip_protocol = "-1" means "all protocols, all ports." Each tier gets two egress rules (one for IPv4, one for IPv6) so resources can reach the internet regardless of address family.

  • Add the final two egress rules for the data tier:
resource "aws_vpc_security_group_egress_rule" "data_all_ipv4" {
  security_group_id = aws_security_group.data.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
  description       = "All outbound IPv4"
}

resource "aws_vpc_security_group_egress_rule" "data_all_ipv6" {
  security_group_id = aws_security_group.data.id
  cidr_ipv6         = "::/0"
  ip_protocol       = "-1"
  description       = "All outbound IPv6"
}
  • Save modules/security-groups/main.tf and run terraform fmt one more time to confirm:
terraform fmt modules/security-groups/main.tf

No output confirms the file is syntactically valid and properly formatted.

  • Now define the module inputs. Open modules/security-groups/variables.tf and add:
variable "environment" {
  description = "Environment name"
  type        = string
}

variable "vpc_id" {
  description = "ID of the VPC"
  type        = string
}

variable "app_port" {
  description = "Port for the application tier"
  type        = number
}

variable "db_port" {
  description = "Port for the database tier"
  type        = number
}

What does this code do?

These four inputs make the module reusable. The vpc_id comes from your VPC module's output (dependency inversion). The port numbers are configurable so teams can adjust them per environment without editing module code.

  • Open modules/security-groups/outputs.tf and expose the security group IDs:
output "web_sg_id" {
  description = "ID of the web tier security group"
  value       = aws_security_group.web.id
}

output "app_sg_id" {
  description = "ID of the app tier security group"
  value       = aws_security_group.app.id
}

output "data_sg_id" {
  description = "ID of the data tier security group"
  value       = aws_security_group.data.id
}
  • Save both files and verify their formatting:
terraform fmt modules/security-groups/

If any filenames print, Terraform reformatted them for you. No output means they are clean.

Seeing a syntax error?

  • Make sure each variable block in variables.tf has matching opening and closing braces.
  • Confirm outputs.tf references the exact resource names (aws_security_group.web, aws_security_group.app, aws_security_group.data).

help me fix a syntax error in my security groups module files

Connect the module and deploy

The module is complete but invisible to Terraform until the root configuration calls it. You will wire the security group module into main.tf and pass it the VPC ID from the VPC module's output.

  • Open the root main.tf and add the security groups module call below the existing VPC module block:
module "security_groups" {
  source = "./modules/security-groups"

  environment = var.environment
  vpc_id      = module.vpc.vpc_id
  app_port    = var.app_port
  db_port     = var.db_port
}

What does this code do?

This is module composition in action. The security group module receives vpc_id from the VPC module's output. Terraform understands this dependency and always creates the VPC before the security groups.

  • Open the root outputs.tf and add these outputs below the existing VPC outputs:
output "web_security_group_id" {
  description = "ID of the web tier security group"
  value       = module.security_groups.web_sg_id
}

output "app_security_group_id" {
  description = "ID of the app tier security group"
  value       = module.security_groups.app_sg_id
}

output "data_security_group_id" {
  description = "ID of the data tier security group"
  value       = module.security_groups.data_sg_id
}
  • Save both files. Before you deploy, what do you think terraform apply will do? The VPC already exists. Will Terraform recreate it, or only add the new security group resources?
  • Deploy by running:
terraform apply -var-file=dev.tfvars

Terraform plans to add 15 new resources (3 security groups + 12 rules) and change nothing about the existing VPC infrastructure. Type yes to confirm.

  • After the apply completes, verify the outputs by running:
terraform output

You should see web_security_group_id, app_security_group_id, and data_security_group_id listed alongside your existing VPC outputs.

  • Open the AWS Management Console and navigate to VPC > Security groups.
  • Find the security group named dev-app- (with a random suffix).
  • Click its Inbound rules tab. Confirm it allows inbound on port 8080 from the web security group (not from 0.0.0.0/0).

Don't see the security groups?

  • Make sure you are in the us-east-1 region in the AWS console (top right dropdown).
  • Check that terraform apply completed without errors. If it failed, read the error message for missing variables or typos in resource names.
  • Verify main.tf passes vpc_id = module.vpc.vpc_id to the module. A missing VPC ID causes a creation error.

help me troubleshoot my security group module deployment

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in modules/security-groups/ and the root main.tf and outputs.tf.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these chunks is present in order in modules/security-groups/main.tf:

  • Web SG definition with name_prefix and create_before_destroy lifecycle.
  • Four web ingress rules: web_http, web_http_ipv6, web_https, web_https_ipv6.
  • App SG definition + app_from_web ingress rule with referenced_security_group_id.
  • Data SG definition + data_from_app ingress rule with referenced_security_group_id.
  • Six egress rules (two per SG) with ip_protocol = "-1".

Here is the complete modules/security-groups/variables.tf:

variable "environment" {
  description = "Environment name"
  type        = string
}

variable "vpc_id" {
  description = "ID of the VPC"
  type        = string
}

variable "app_port" {
  description = "Port for the application tier"
  type        = number
}

variable "db_port" {
  description = "Port for the database tier"
  type        = number
}

Here is the complete modules/security-groups/outputs.tf:

output "web_sg_id" {
  description = "ID of the web tier security group"
  value       = aws_security_group.web.id
}

output "app_sg_id" {
  description = "ID of the app tier security group"
  value       = aws_security_group.app.id
}

output "data_sg_id" {
  description = "ID of the data tier security group"
  value       = aws_security_group.data.id
}

Here is the complete root main.tf:

module "vpc" {
  source = "./modules/vpc"

  environment        = var.environment
  vpc_cidr           = var.vpc_cidr
  availability_zones = var.availability_zones
  web_subnet_cidrs   = var.web_subnet_cidrs
  app_subnet_cidrs   = var.app_subnet_cidrs
  data_subnet_cidrs  = var.data_subnet_cidrs
}

module "security_groups" {
  source = "./modules/security-groups"

  environment = var.environment
  vpc_id      = module.vpc.vpc_id
  app_port    = var.app_port
  db_port     = var.db_port
}

Here is the complete root outputs.tf:

output "vpc_id" {
  description = "The ID of the VPC"
  value       = module.vpc.vpc_id
}

output "vpc_ipv6_cidr_block" {
  description = "The IPv6 CIDR block of the VPC"
  value       = module.vpc.vpc_ipv6_cidr_block
}

output "web_subnet_ids" {
  description = "IDs of the web (public) subnets"
  value       = module.vpc.web_subnet_ids
}

output "app_subnet_ids" {
  description = "IDs of the app (private) subnets"
  value       = module.vpc.app_subnet_ids
}

output "data_subnet_ids" {
  description = "IDs of the data (private) subnets"
  value       = module.vpc.data_subnet_ids
}

output "web_security_group_id" {
  description = "ID of the web tier security group"
  value       = module.security_groups.web_sg_id
}

output "app_security_group_id" {
  description = "ID of the app tier security group"
  value       = module.security_groups.app_sg_id
}

output "data_security_group_id" {
  description = "ID of the data tier security group"
  value       = module.security_groups.data_sg_id
}

Your security group module is live and enforcing the three-tier pattern. Next up, you will prove these modules are truly reusable by deploying a second environment with completely different CIDRs.

Deploy to Multiple Environments

You now have a fully functional dev network with security groups enforcing exactly which tiers can talk to each other. The real test of a Terraform module is whether you can reuse it without modifying a single line of module code.

In this step, you will deploy a second environment (prod) using different CIDRs and the exact same modules. If it works, your modules are genuine composable building blocks, not just scripts that happened to run once.

In this step, get ready to:
  • Create a prod variable file with a different VPC CIDR range.
  • Use Terraform workspaces to isolate prod state from dev.
  • Deploy and verify a complete prod network from the same module code.
Create the prod variable file

Your dev.tfvars file tells the modules to use the 10.0.0.0/16 CIDR range. A production environment needs its own non-overlapping address space so the two networks could peer without conflicts.

  • Create a new file called prod.tfvars in your project root (the same directory as dev.tfvars) with the following content:
environment        = "prod"
aws_region         = "us-east-1"
vpc_cidr           = "10.1.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
web_subnet_cidrs   = ["10.1.1.0/24", "10.1.2.0/24"]
app_subnet_cidrs   = ["10.1.11.0/24", "10.1.12.0/24"]
data_subnet_cidrs  = ["10.1.21.0/24", "10.1.22.0/24"]
app_port           = 8080
db_port            = 5432

What does this file do?

This file uses the exact same variable names as dev.tfvars but with different values. The VPC CIDR shifts from 10.0.0.0/16 to 10.1.0.0/16, and all subnet CIDRs follow the same pattern with 10.1.x.x addresses.

The module code does not change at all. Only the inputs change, which is exactly how reusable modules are supposed to work.

  • Save prod.tfvars.
  • Confirm the file exists by running:
ls *.tfvars

You should see both dev.tfvars and prod.tfvars listed.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

environment        = "prod"
aws_region         = "us-east-1"
vpc_cidr           = "10.1.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
web_subnet_cidrs   = ["10.1.1.0/24", "10.1.2.0/24"]
app_subnet_cidrs   = ["10.1.11.0/24", "10.1.12.0/24"]
data_subnet_cidrs  = ["10.1.21.0/24", "10.1.22.0/24"]
app_port           = 8080
db_port            = 5432
Create a prod workspace

Terraform workspaces let you maintain separate state files for the same configuration in one directory. Without a workspace, deploying prod would overwrite your dev state and destroy those resources.

  • Create and switch to a new workspace called prod by running this command:
terraform workspace new prod

You should see:

Created and switched to workspace "prod"!

Why workspaces instead of separate directories?

Workspaces isolate state without duplicating code. Your dev infrastructure lives in the default workspace state file, and prod gets its own. Both point at the same .tf files, so any module improvement automatically applies to both environments on the next apply.

Seeing a workspace already exists error?

If you previously created a prod workspace, switch to it instead by running terraform workspace select prod.

help me fix a Terraform workspace error

Plan and deploy the prod environment

Now for the payoff. You will run a plan against the prod variable file and confirm that Terraform wants to create a complete, separate network stack.

  • Preview what Terraform will create for prod by running:
terraform plan -var-file=prod.tfvars

Scan the plan output. You should see resource names prefixed with prod- (like prod-vpc, prod-web-sg) and subnet CIDRs in the 10.1.x.x range. The plan should show the same resource count as your dev deployment but with entirely different naming and addressing.

What should I see in the plan?

The plan shows Terraform will add resources (VPC, subnets, route tables, gateways, security groups) and change or destroy zero. This confirms the prod workspace has no existing state, so everything is brand new.

Before you apply, what do you think will happen? Will Terraform touch your dev resources, or create an entirely separate stack?

  • Deploy the prod environment by running:
terraform apply -var-file=prod.tfvars
  • When prompted, type yes and press Enter to confirm.

Terraform creates the full prod network stack. Your dev resources are untouched because they live in a separate workspace state.

  • View the prod outputs by running:
terraform output

You should see a new VPC ID, subnet IDs, and security group IDs for your prod environment. These are completely separate resources from the dev ones you created earlier.

How do I confirm dev is still intact?

You can switch back and check by running terraform workspace select default followed by terraform output. You will see your original dev VPC ID with 10.0.0.0/16 addressing, completely independent of prod. Switch back to prod with terraform workspace select prod when you are done.

Apply failed or timed out?

If the NAT gateway creation takes longer than expected, wait a few minutes. NAT gateways can take 1-2 minutes to provision.

If you see a CIDR conflict error, confirm your prod.tfvars uses 10.1.0.0/16 (not 10.0.0.0/16). Each environment must have its own non-overlapping address space.

help me fix a failed Terraform apply for my prod environment

Secret mission

Add a VPC Flow Logs Module

Your network is deployed and secured, but you have no visibility into what traffic is actually flowing through it. Can you build a third Terraform module from scratch that enables VPC Flow Logs, complete with IAM roles and CloudWatch integration?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project provisions NAT gateways and Elastic IPs that incur hourly charges for each deployed environment.

Cost warning

Each environment (dev and prod) costs approximately $1.20/day while running. The NAT gateway charges $0.045/hour and the Elastic IP charges $0.005/hour. If you leave both environments deployed, you will be charged roughly $2.40/day.

If you are not actively using these resources, choose Pause or Delete below to stop all charges immediately.

Resources you used:

  • AWS VPC, subnets, internet gateway, route tables (dev environment, default workspace).
  • NAT gateway and Elastic IP (dev environment, default workspace).
  • Security groups for web, app, and data tiers (dev environment, default workspace).
  • VPC Flow Logs, CloudWatch Log Group, and IAM role (dev environment, Secret Mission).
  • AWS VPC, subnets, NAT gateway, Elastic IP, security groups (prod environment, prod workspace).
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively experimenting with your Terraform modules or plan to extend them further.

  • Your dev and prod environments will remain deployed in AWS.
  • Charges of approximately $1.20/day per environment will continue to accrue for the NAT gateway and Elastic IP.
  • Your local Terraform state files and project code remain on your machine.

Pause - I'll come back to this later

Destroy the AWS resources to stop all charges, but keep your local project files so you can re-deploy later with a single command.

  • Switch to the prod workspace and destroy those resources by running:
terraform workspace select prod
terraform destroy -var-file=prod.tfvars
  • Type yes when prompted to confirm destruction.
  • Switch back to the default workspace and destroy the dev resources by running:
terraform workspace select default
terraform destroy -var-file=dev.tfvars
  • Type yes when prompted to confirm destruction.
  • Your local files stay intact. When you are ready to return, run terraform apply -var-file=dev.tfvars to redeploy.

Delete - I don't want to use this again

Remove all AWS resources and delete the local project files entirely.

  • Switch to the prod workspace and destroy those resources by running:
terraform workspace select prod
terraform destroy -var-file=prod.tfvars
  • Type yes when prompted to confirm destruction.
  • Switch back to the default workspace and destroy the dev resources by running:
terraform workspace select default
terraform destroy -var-file=dev.tfvars
  • Type yes when prompted to confirm destruction.
  • Navigate up one level from your project folder and delete it by running:

macOS

cd ~/Desktop
rm -rf terraform-three-tier-network

Windows

cd ~\Desktop
rmdir /s /q terraform-three-tier-network
  • All AWS resources and local files are now removed. No further charges will accrue.

Nice Work!

Nice Work!

Nice work! You just built a production-grade, reusable Terraform module system that deploys a complete dual-stack three-tier AWS network with a single terraform apply.

You've learned how to:

  • Built a reusable VPC module that provisions six subnets (web/app/data) across 2 AZs with IPv6 dual-stack addressing and full route table configuration.
  • Diagnosed and fixed private subnet isolation by adding a NAT gateway for IPv4 outbound and an egress-only internet gateway for IPv6 outbound.
  • Built a security group module that enforces tier-to-tier traffic flow using referenced_security_group_id instead of CIDR blocks.
  • Proved module reusability by deploying identical dev and prod environments from the same module code using different variable files and Terraform workspaces.
  • (Secret Mission) Added network observability by creating a VPC Flow Logs module with CloudWatch integration and a dedicated IAM role.

Ready to quiz yourself?