Build AWS Network with Terraform

Deploy a secure two-AZ AWS network foundation with Terraform.

Introduction

30 Second Summary

A reliable system needs clear boundaries before anything moves through it. Without those boundaries, every new component inherits guesses that become expensive to untangle later.

In this project, you will use Terraform to build a secure Amazon Web Services network foundation across two Availability Zones. The finished network will keep public access, application capacity, and database capacity in clearly defined tiers.

What You'll Build

You will run Terraform to create a two-Availability-Zone network whose six subnets, controlled routes, and tier boundaries are visible in terminal output and an editable architecture diagram.

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

  • A deployable VPC with six named subnets whose AWS IDs are printed by Terraform across public, application, and database tiers.
  • A routing design you can inspect to demonstrate public internet access, controlled NAT egress, and isolated database capacity across two Availability Zones.
  • An editable diagrams.net architecture diagram that shows the subnet tiers, routing paths, S3 gateway endpoint, PostgreSQL trust path, and deliberately deferred application edge.
  • Secret Mission: Create an out-of-band VPC tag change that Terraform detects and reconciles to the declared configuration.

Are there any prerequisites?

You need a 64-bit Linux workstation with outbound internet access. You also need an AWS account that can create and tag VPC networking resources.

Before We Start

The future Java application has no confirmed runtime contract, so its Application Load Balancer listener and application port remain deferred. You can already commit to Amazon S3 access over HTTPS and PostgreSQL access on port 5432 because those network requirements are known.

Prepare Your Linux Terraform Workstation

Future network changes must land in the intended account. Known tool versions keep every later infrastructure plan consistent.

This step pins Terraform CLI 1.17.1 on your Linux workstation. It also confirms AWS CLI v2 in the same terminal.

Your final identity check proves which Amazon Web Services account receives future changes. An empty workspace then gives the network code a predictable home.

In this step, get ready to:
  • Confirm Terraform CLI 1.17.1.
  • Confirm AWS CLI v2.
  • Verify AWS access from an empty project directory.
Check Terraform 1.17.1

Terraform converts infrastructure configuration into a plan you can review before creating resources. Release 1.17.1 matches the exact version declared by this project.

  • Start a terminal session from your Linux application launcher.
  • Check the Terraform version available in your terminal by running:
terraform version

What does this check do?

The command asks the local Terraform binary to identify its release. The first output line reveals whether the required binary is ready.

The result determines which path applies to your workstation.

✔️ I see version 1.17.1

Your output begins with Terraform v1.17.1. Your pinned Terraform binary is ready.

ⓧ I see an older version

Your existing Terraform release needs an upgrade. Choose the package manager used by your Linux distribution.

Debian or Ubuntu

The official APT path adds HashiCorp as a trusted package source. The repository currently provides Terraform 1.17.1.

  • Allow elevated package installation if your terminal requests your Linux account password.
  • Upgrade Terraform through the official APT repository by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
What do these commands do?
  • The first command installs HashiCorp's package-signing key.
  • The second command registers the official APT repository.
  • The final command refreshes the package index before installing Terraform.
  • Approve the installation if APT requests confirmation.
  • Confirm the installed Terraform release by running:
terraform version
What does this confirmation prove?

The command confirms which Terraform binary your terminal now resolves. The first output line should identify release 1.17.1.

That upgrade closes the version gap. Your workstation now matches the project pin.

RHEL or CentOS

The official YUM path registers HashiCorp as a package source. The repository currently provides Terraform 1.17.1.

  • Allow elevated package installation if your terminal requests your Linux account password.
  • Upgrade Terraform through the official YUM repository by running these commands:
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum -y install terraform
What do these commands do?
  • The first command installs the repository management utility.
  • The second command registers HashiCorp's RHEL package repository.
  • The final command installs Terraform from that repository.
  • Confirm the installed Terraform release by running:
terraform version
What does this confirmation prove?

The command confirms which Terraform binary your terminal now resolves. The first output line should identify release 1.17.1.

The older release is now replaced. Your workstation matches the project pin.

Fedora

The official DNF path saves HashiCorp's repository definition before installing Terraform. The repository currently provides release 1.17.1.

  • Allow elevated package installation if your terminal requests your Linux account password.
  • Upgrade Terraform through the official DNF repository by running these commands:
wget -O- https://rpm.releases.hashicorp.com/fedora/hashicorp.repo | sudo tee /etc/yum.repos.d/hashicorp.repo
sudo yum list available | grep hashicorp
sudo dnf -y install terraform
What do these commands do?
  • The first command saves HashiCorp's Fedora repository definition.
  • The second command confirms that HashiCorp packages are available.
  • The final command installs Terraform through DNF.
  • Confirm the installed Terraform release by running:
terraform version
What does this confirmation prove?

The command confirms which Terraform binary your terminal now resolves. The first output line should identify release 1.17.1.

The version mismatch is resolved. Your workstation now matches the project pin.

ⓧ Command not found

Terraform is absent from your current command path. Choose the package manager used by your Linux distribution.

Debian or Ubuntu

The official APT path adds HashiCorp as a trusted package source. The repository currently provides Terraform 1.17.1.

  • Allow elevated package installation if your terminal requests your Linux account password.
  • Install Terraform through the official APT repository by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
What do these commands do?
  • The first command installs HashiCorp's package-signing key.
  • The second command registers the official APT repository.
  • The final command refreshes the package index before installing Terraform.
  • Approve the installation if APT requests confirmation.
  • Confirm the installed Terraform release by running:
terraform version
What does this confirmation prove?

The command confirms that Terraform is now available in your terminal. The first output line should identify release 1.17.1.

Terraform is now installed. Your workstation matches the project pin.

RHEL or CentOS

The official YUM path registers HashiCorp as a package source. The repository currently provides Terraform 1.17.1.

  • Allow elevated package installation if your terminal requests your Linux account password.
  • Install Terraform through the official YUM repository by running these commands:
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum -y install terraform
What do these commands do?
  • The first command installs the repository management utility.
  • The second command registers HashiCorp's RHEL package repository.
  • The final command installs Terraform from that repository.
  • Confirm the installed Terraform release by running:
terraform version
What does this confirmation prove?

The command confirms that Terraform is now available in your terminal. The first output line should identify release 1.17.1.

Terraform is now installed. Your workstation matches the project pin.

Fedora

The official DNF path saves HashiCorp's repository definition before installing Terraform. The repository currently provides release 1.17.1.

  • Allow elevated package installation if your terminal requests your Linux account password.
  • Install Terraform through the official DNF repository by running these commands:
wget -O- https://rpm.releases.hashicorp.com/fedora/hashicorp.repo | sudo tee /etc/yum.repos.d/hashicorp.repo
sudo yum list available | grep hashicorp
sudo dnf -y install terraform
What do these commands do?
  • The first command saves HashiCorp's Fedora repository definition.
  • The second command confirms that HashiCorp packages are available.
  • The final command installs Terraform through DNF.
  • Confirm the installed Terraform release by running:
terraform version
What does this confirmation prove?

The command confirms that Terraform is now available in your terminal. The first output line should identify release 1.17.1.

Terraform is now installed. Your workstation matches the project pin.

Terraform installation failing?

  • Confirm that your workstation can reach the internet.
  • Confirm that you selected the package manager for your Linux distribution.
  • Confirm that your Linux account can perform elevated package installation.

Help me diagnose my Terraform installation.

Check AWS CLI v2

The AWS CLI sends terminal requests to your AWS account. This project requires the v2 release family for identity checks plus later route-table inspection.

  • Check the AWS CLI version available in your terminal by running:
aws --version

What does this check do?

The command prints the installed AWS CLI release. The beginning of the output reveals which release family your terminal uses.

The result determines whether your current installation is ready.

✔️ I see AWS CLI v2

Your output identifies the AWS CLI v2 release family. The local interface for AWS commands is ready.

ⓧ I see AWS CLI v1

Your terminal is using the earlier release family. The official installer adds AWS CLI v2 for your Linux user.

  • Install AWS CLI v2 for your user by running:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What does this command do?

The command downloads the official AWS CLI installer over HTTPS. Bash runs that installer for your current Linux user.

  • Close your terminal session if the earlier release remains active.
  • Start a fresh terminal session from your Linux application launcher.
  • Confirm the active AWS CLI release by running:
aws --version

What does this confirmation prove?

The command confirms which AWS CLI installation your fresh terminal resolves. The output should identify the v2 release family.

The release mismatch is resolved. Your terminal is ready for the project's AWS commands.

ⓧ Command not found

The AWS CLI is absent from your current command path. The official installer adds AWS CLI v2 for your Linux user.

  • Install AWS CLI v2 for your user by running:
curl -fsSL https://awscli.amazonaws.com/v2/install.sh | bash

What does this command do?

The command downloads the official AWS CLI installer over HTTPS. Bash runs that installer for your current Linux user.

  • Close your terminal session if the new command remains unavailable.
  • Start a fresh terminal session from your Linux application launcher.
  • Confirm the active AWS CLI release by running:
aws --version

What does this confirmation prove?

The command confirms that the AWS CLI is now available in your terminal. The output should identify the v2 release family.

AWS CLI is now installed. Your terminal is ready for authenticated AWS requests.

AWS CLI installation failing?

  • Confirm that your workstation can reach the installer URL.
  • Confirm that the download command is available in your terminal.
  • Restart your terminal session if installation succeeds but the command remains unavailable.

Help me diagnose my AWS CLI installation.

Verify AWS access and prepare the workspace

AWS authentication connects your terminal to a specific account identity. The identity check confirms that connection before any infrastructure exists.

Credentials are sensitive. This check returns identity details without displaying secret access values.

  • Activate the temporary or federated AWS credential method supplied by your account.
  • Verify the active AWS identity by running:
aws sts get-caller-identity

What does this identity check prove?

AWS returns the active account ID. It also returns the user ID plus principal ARN.

This response proves that your terminal can make authenticated AWS requests. It identifies the account that receives future network resources.

You should see identity data for the account where the network foundation belongs.

Identity check unsuccessful?

  • Refresh expired temporary credentials through your account's existing login method.
  • Confirm that the current terminal session received the refreshed credentials.
  • Keep access keys plus session tokens out of screenshots or help requests.

Help me troubleshoot AWS authentication safely.

The project directory gives every future Terraform file a predictable home. A blank directory also makes the next step's file changes easy to verify.

  • Create ~/projects/tf-network-foundation and enter it by running these commands:
mkdir -p ~/projects/tf-network-foundation
cd ~/projects/tf-network-foundation

What do these commands do?

  • The first command creates the project directory plus any missing parent directory.
  • The second command moves your terminal into the new directory.
  • Confirm the current location plus inspect the empty directory by running:
pwd
ls -A

What should I see?

The printed path ends with projects/tf-network-foundation. The directory listing should be blank.

A blank listing confirms that no configuration files or state artifacts exist yet.

Before you run the final check, which tool versions plus account identity do you expect your terminal to confirm?

  • Run the final workstation verification from ~/projects/tf-network-foundation with these commands:
terraform version
aws --version
aws sts get-caller-identity

What does the final check prove?

  • Terraform identifies the exact release that produces your infrastructure plans.
  • The AWS CLI identifies the release family used for AWS requests.
  • AWS returns the account plus principal behind future infrastructure changes.

You should see Terraform v1.17.1. You should also see AWS CLI v2 plus your authenticated AWS identity.

That is the workstation contract confirmed. Your empty workspace can now produce plans for the intended AWS account.

Your pinned tools plus authenticated AWS session are ready. Next, you will turn the empty directory into a VPC with six subnets across two Availability Zones.

Create the VPC and Subnet Skeleton

Your Linux workstation is ready. Your authenticated AWS session gives Terraform permission to turn configuration into networking resources.

The network now needs a visible skeleton. You will create one VPC with six subnets across two Availability Zones.

In this step, get ready to:
  • Pin Terraform 1.17.1 with AWS provider 6.72.0.
  • Model the six-subnet address plan in Terraform.
  • Deploy the VPC skeleton to AWS.
Pin the root module and inputs

A version-pinned root module records the Terraform version plus the provider version. These constraints keep future initialization consistent.

  • Load ~/projects/tf-network-foundation in your code editor using its folder-opening control.
  • Create .gitignore inside ~/projects/tf-network-foundation.
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
crash.*.log

What does this file protect?

The ignore rules keep generated provider files plus local state files outside version control. Your reusable Terraform configuration remains separate from machine-specific artifacts.

  • Create versions.tf beside .gitignore.
terraform {
  required_version = "1.17.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.72.0"
    }
  }
}

What does this configuration do?

  • The required_version constraint fixes the Terraform workflow to 1.17.1.
  • The required_providers block selects hashicorp/aws version 6.72.0.
  • Save .gitignore.
  • Save versions.tf.
  • Switch back to the terminal from earlier.
  • Initialize the Terraform root module by running:
terraform init

What does this command do?

Terraform downloads the selected provider. It records that selection plus provider checksums in .terraform.lock.hcl.

That is your first reproducibility guard in place. Future initialization can use the same recorded provider selection.

  • Open .terraform.lock.hcl in your code editor.
  • Confirm that the file records hashicorp/aws plus provider checksums.

Initialization did not complete?

Confirm that your terminal is still inside ~/projects/tf-network-foundation. Check that outbound internet access is available for the provider download.

Verify that versions.tf matches the code above.

Help me diagnose Terraform initialization in this project.

Variables give the network reusable inputs. Validation blocks stop unsupported values before Terraform changes AWS.

  • Create variables.tf inside ~/projects/tf-network-foundation.
  • Add the project name variable by pasting:
variable "project_name" {
  type        = string
  description = "Name prefix and Project tag for the network foundation."
  default     = "tf-network-foundation"

  validation {
    condition     = length(var.project_name) >= 3 && length(var.project_name) <= 24
    error_message = "project_name must contain between 3 and 24 characters."
  }
}

How is the project name checked?

The default name becomes a shared prefix plus a Project tag. The validation accepts names containing between 3 and 24 characters.

  • Add the AWS Region variable below project_name by pasting:
variable "aws_region" {
  type        = string
  description = "AWS Region used by this verified cost-controlled project."
  default     = "us-east-2"

  validation {
    condition     = var.aws_region == "us-east-2"
    error_message = "This project is verified for us-east-2 so its pricing and endpoint behavior remain aligned with the guide."
  }
}

Why validate the Region?

The Region validation keeps this project fixed to us-east-2. That keeps its network behavior aligned with the verified design.

  • Save variables.tf.
  • Check the current configuration by running:
terraform validate

What should I see?

Terraform confirms that the current configuration is internally consistent. This proves the version constraints plus the first two variables are valid.

  • Create providers.tf beside variables.tf.
provider "aws" {
  region = var.aws_region
}

How does the provider choose a Region?

The provider reads the validated aws_region variable. Every AWS resource in this root module uses that Region.

  • Add the VPC CIDR variable below aws_region in variables.tf.
variable "vpc_cidr" {
  type        = string
  description = "IPv4 CIDR block divided into six /24 subnets."
  default     = "10.0.0.0/16"
}

What does the VPC CIDR represent?

The 10.0.0.0/16 range supplies the address space for the complete VPC. Terraform derives six smaller ranges from it.

  • Save providers.tf.
  • Save variables.tf.
  • Validate the expanded configuration by running:
terraform validate

What does this check prove?

Terraform confirms that the provider can reference var.aws_region. It also confirms that the new CIDR input is valid Terraform configuration.

  • Add the database port variable below vpc_cidr in variables.tf.
variable "database_port" {
  type        = number
  description = "Future PostgreSQL listener port allowed from the application tier."
  default     = 5432

  validation {
    condition     = var.database_port > 0 && var.database_port < 65536
    error_message = "database_port must be between 1 and 65535."
  }
}

Why define the port now?

The validated port prepares a known PostgreSQL trust path for a later step. Its range check rejects values outside valid TCP port numbers.

Locals turn one address plan into stable logical names. Each subnet records its tier plus an Availability Zone index plus a subnet number.

  • Create locals.tf beside variables.tf.
  • Add the public plus application subnet definitions by pasting:
locals {
  subnets = {
    public-a = {
      tier     = "public"
      az_index = 0
      netnum   = 0
    }
    public-b = {
      tier     = "public"
      az_index = 1
      netnum   = 1
    }
    app-a = {
      tier     = "application"
      az_index = 0
      netnum   = 10
    }
    app-b = {
      tier     = "application"
      az_index = 1
      netnum   = 11
    }
  }
}

How does this map organize subnets?

  • Each map key becomes a stable subnet name.
  • The az_index value chooses the first or second discovered Availability Zone.
  • The netnum value selects a unique subnet range inside the VPC CIDR.
  • Save variables.tf.
  • Save locals.tf.
  • Validate the input model by running:
terraform validate

What should I see?

Terraform confirms that the variables plus the initial subnet map are valid. The model is ready for its database tier.

  • In locals.tf, find the closing brace directly below app-b.
  • Insert the database subnet definitions above that brace:
    db-a = {
      tier     = "database"
      az_index = 0
      netnum   = 20
    }
    db-b = {
      tier     = "database"
      az_index = 1
      netnum   = 21
    }

How are the database ranges separated?

The database subnets use subnet numbers 20 plus 21. Their ranges stay distinct from the public plus application tiers.

  • Add the public subnet filter below the complete subnets map.
  public_subnets = {
    for name, subnet in local.subnets : name => subnet
    if subnet.tier == "public"
  }

What does this expression produce?

The expression keeps entries whose tier is public. Later route-table associations can target that filtered map.

  • Save locals.tf.
  • Validate the updated local values by running:
terraform validate

What does this result confirm?

Terraform accepts all six subnet entries plus the public-tier filter. The remaining filters can reuse the same pattern.

  • Add the application subnet filter below public_subnets.
  app_subnets = {
    for name, subnet in local.subnets : name => subnet
    if subnet.tier == "application"
  }

What does this filter select?

The expression creates a map containing app-a plus app-b.

  • Add the database subnet filter below app_subnets.
  db_subnets = {
    for name, subnet in local.subnets : name => subnet
    if subnet.tier == "database"
  }

What does the final filter select?

The expression creates a map containing db-a plus db-b.

  • Save locals.tf.
  • Validate the complete input model by running:
terraform validate

What should I see?

Terraform confirms that the four variables plus all four local maps are internally consistent. Your six-subnet design is ready to become AWS resources.

Variable or local validation failed?

Compare each variable name against the snippets above. Check that every subnet entry sits inside the subnets map.

Check the closing braces in variables.tf plus locals.tf.

Help me debug my Terraform variables and locals.

Define the VPC, subnets, and outputs

A data source can discover account-accessible Availability Zones without creating them. The resource blocks can then use those discovered names to place the subnets.

  • Create network.tf inside ~/projects/tf-network-foundation.
  • Add the Availability Zone data source by pasting:
data "aws_availability_zones" "available" {
  state = "available"

  filter {
    name   = "opt-in-status"
    values = ["opt-in-not-required"]
  }
}

What does the data source discover?

The data source asks AWS for standard available Availability Zones. Its names result supplies the first two placements for this network.

  • Add the VPC resource below the data source.
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = var.project_name
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

What does the VPC resource establish?

  • The resource uses the validated vpc_cidr value.
  • DNS support plus DNS hostnames are enabled for future workloads.
  • The shared tags identify the project plus its management source.
  • Save network.tf.
  • Validate the first resource definition by running:
terraform validate

What does this check prove?

Terraform confirms that the data source plus VPC resource use valid provider arguments. No AWS resources are created during validation.

  • Add the subnet resource below aws_vpc.main in network.tf.
resource "aws_subnet" "this" {
  for_each = local.subnets

  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, each.value.netnum)
  availability_zone       = data.aws_availability_zones.available.names[each.value.az_index]
  map_public_ip_on_launch = each.value.tier == "public"

  tags = {
    Name        = "${var.project_name}-${each.key}"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
    Tier        = each.value.tier
  }
}

How does one block create six subnets?

  • The for_each expression creates one subnet for every entry in local.subnets.
  • The cidrsubnet expression derives a /24 range from each subnet number.
  • The Availability Zone index alternates resources across the first two discovered zones.
  • Public IP mapping becomes true only for the public tier.
  • Save network.tf.
  • Validate the resource graph by running:
terraform validate

What should I see?

Terraform confirms that the subnet instances can reference the VPC plus the discovered Availability Zones. It also accepts the CIDR expression plus the conditional public IP setting.

Outputs expose useful resource IDs after deployment. The VPC gets one scalar output while the six subnet instances become a map.

  • Create outputs.tf beside network.tf.
  • Add the VPC ID output by pasting:
output "vpc_id" {
  description = "ID of the network foundation VPC."
  value       = aws_vpc.main.id
}

What does this output expose?

The output makes the deployed VPC ID available without searching the AWS console. Later commands can read the value from Terraform state.

  • Add the subnet ID output below vpc_id.
output "subnet_ids" {
  description = "Subnet IDs keyed by stable logical names."
  value = {
    for name, subnet in aws_subnet.this : name => subnet.id
  }
}

Why return a map?

The expression preserves each logical subnet name beside its AWS ID. You can identify a subnet by role without relying on creation order.

  • Save outputs.tf.
  • Validate the complete resource graph by running:
terraform validate

What does this validation confirm?

Terraform confirms that both outputs reference resources in the current root module. All seven configuration files are now connected.

Resource graph not validating?

Check that network.tf uses aws_vpc.main plus aws_subnet.this exactly. Confirm that outputs.tf uses the same resource names.

Help me debug the VPC and subnet resource graph.

✔️ Awesome, I've got everything!

Great. Double check that all seven files are saved before you plan the deployment.

ⓧ I'd like to double check the full code

Compare each saved file against the complete configuration below.

.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
crash.*.log
terraform {
  required_version = "1.17.1"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.72.0"
    }
  }
}
provider "aws" {
  region = var.aws_region
}
variable "project_name" {
  type        = string
  description = "Name prefix and Project tag for the network foundation."
  default     = "tf-network-foundation"

  validation {
    condition     = length(var.project_name) >= 3 && length(var.project_name) <= 24
    error_message = "project_name must contain between 3 and 24 characters."
  }
}

variable "aws_region" {
  type        = string
  description = "AWS Region used by this verified cost-controlled project."
  default     = "us-east-2"

  validation {
    condition     = var.aws_region == "us-east-2"
    error_message = "This project is verified for us-east-2 so its pricing and endpoint behavior remain aligned with the guide."
  }
}

variable "vpc_cidr" {
  type        = string
  description = "IPv4 CIDR block divided into six /24 subnets."
  default     = "10.0.0.0/16"
}

variable "database_port" {
  type        = number
  description = "Future PostgreSQL listener port allowed from the application tier."
  default     = 5432

  validation {
    condition     = var.database_port > 0 && var.database_port < 65536
    error_message = "database_port must be between 1 and 65535."
  }
}
locals {
  subnets = {
    public-a = {
      tier     = "public"
      az_index = 0
      netnum   = 0
    }
    public-b = {
      tier     = "public"
      az_index = 1
      netnum   = 1
    }
    app-a = {
      tier     = "application"
      az_index = 0
      netnum   = 10
    }
    app-b = {
      tier     = "application"
      az_index = 1
      netnum   = 11
    }
    db-a = {
      tier     = "database"
      az_index = 0
      netnum   = 20
    }
    db-b = {
      tier     = "database"
      az_index = 1
      netnum   = 21
    }
  }

  public_subnets = {
    for name, subnet in local.subnets : name => subnet
    if subnet.tier == "public"
  }

  app_subnets = {
    for name, subnet in local.subnets : name => subnet
    if subnet.tier == "application"
  }

  db_subnets = {
    for name, subnet in local.subnets : name => subnet
    if subnet.tier == "database"
  }
}
data "aws_availability_zones" "available" {
  state = "available"

  filter {
    name   = "opt-in-status"
    values = ["opt-in-not-required"]
  }
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = var.project_name
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_subnet" "this" {
  for_each = local.subnets

  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, each.value.netnum)
  availability_zone       = data.aws_availability_zones.available.names[each.value.az_index]
  map_public_ip_on_launch = each.value.tier == "public"

  tags = {
    Name        = "${var.project_name}-${each.key}"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
    Tier        = each.value.tier
  }
}
output "vpc_id" {
  description = "ID of the network foundation VPC."
  value       = aws_vpc.main.id
}

output "subnet_ids" {
  description = "Subnet IDs keyed by stable logical names."
  value = {
    for name, subnet in aws_subnet.this : name => subnet.id
  }
}
Plan, apply, and verify the subnet map

Terraform can now compare the declared network with the empty state. The plan gives you a review point before AWS changes.

  • Apply Terraform's canonical formatting by running:
terraform fmt

What does this command do?

Terraform rewrites configuration files into its canonical style. It prints a file name when that file changes.

  • Validate the formatted configuration by running:
terraform validate

What should I see?

Terraform confirms that the formatted configuration is valid. This is the final local check before the plan reads AWS.

Before you run the plan, how many resources do you expect Terraform to create from one VPC block plus a six-entry subnet map?

  • Preview the AWS changes by running:
terraform plan

What should the plan show?

The plan should contain one VPC plus six subnet instances. The subnet addresses use the logical keys public-a, public-b, app-a, app-b, db-a, plus db-b.

Applying the plan creates real networking resources in your authenticated AWS account. Terraform pauses for confirmation so you can review the plan before anything changes.

  • Start the deployment by running:
  • Enter yes only after the plan shows the expected VPC plus six subnets.
terraform apply

What does apply record?

Terraform creates the planned AWS resources. It records their IDs plus relationships in local state.

You have a live two-Availability-Zone network skeleton. Terraform now manages the VPC plus all six named subnets.

Apply did not complete?

Confirm that your AWS authentication is still active. Check that your account permits VPC plus subnet creation in us-east-2.

If the plan differs from seven resources, compare your local.subnets map against the full-code tab.

Help me diagnose this Terraform apply.

Before you inspect the output, which six logical keys do you expect Terraform to preserve beside the generated subnet IDs?

  • Display the deployed subnet map by running:
terraform output subnet_ids

What should I see?

Terraform prints six keys mapped to AWS subnet IDs. The keys are public-a, public-b, app-a, app-b, db-a, plus db-b.

The stable keys prove that one subnet resource block expanded into six identifiable AWS resources.

Your VPC skeleton is deployed across two Availability Zones. Next, you will give the public tier a route while exposing the private application tier's missing path.

Route the Public Tier

The six-subnet VPC is live in Amazon Web Services. Terraform already tracks each subnet across two Availability Zones.

Public routing gives the public subnets an internet path. Separate application routing lets you inspect which destinations the private tier can currently reach.

In this step, get ready to:
  • Build a route table for each subnet tier.
  • Route public traffic through an Internet Gateway.
  • Inspect the application route table for missing private-tier paths.
Create separate route tables

An Internet Gateway connects a VPC to the internet. A route table controls where traffic from an associated subnet can travel.

  • Switch back to the terminal in ~/projects/tf-network-foundation.
  • Return to network.tf from the previous step.
  • Add the Internet Gateway below the existing aws_subnet.this block by pasting this code:
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-igw"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

What does this resource do?

  • The resource creates an Internet Gateway for the network foundation.
  • The vpc_id reference attaches the gateway to aws_vpc.main.
  • The tags keep the gateway identifiable in AWS.
  • Save network.tf.
  • Check the updated configuration by running:
terraform validate

What does this check prove?

Terraform checks the initialized configuration for valid syntax. It also checks that the Internet Gateway reference matches the existing VPC resource.

You should see confirmation that the configuration is valid.

Internet Gateway validation failing?

Confirm that the new resource sits after the closing brace of aws_subnet.this. Check that its VPC reference is exactly aws_vpc.main.id.

Help me fix the Internet Gateway validation error.

The gateway is attached to the VPC. Each subnet tier now needs its own routing policy.

  • Add the public route table below aws_internet_gateway.main by pasting this code:
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-public"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

What does this resource do?

This resource creates the route table for both public subnets. Its routes define the destinations that public-tier traffic can reach.

  • Save network.tf.
  • Validate the public route table by running:
terraform validate

What does this check prove?

Terraform confirms that the public route table is valid. The configuration remains safe to extend with another tier.

You should see confirmation that the configuration is valid.

Public route table validation failing?

Check that the resource name is aws_route_table.public. Confirm that the resource references aws_vpc.main.id.

Help me fix the public route table validation error.

  • Add the application route table below aws_route_table.public by pasting this code:
resource "aws_route_table" "app" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-app"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

Why use an application route table?

The application subnets need a routing policy that stays independent from the public tier. This separation lets you add controlled outbound paths without making application subnets public.

  • Save network.tf.
  • Validate the application route table by running:
terraform validate

What does this check prove?

Terraform confirms that the application route table is internally consistent. The resource can now receive subnet associations.

You should see confirmation that the configuration is valid.

Application route table validation failing?

Check that the resource name is aws_route_table.app. Confirm that every tag entry remains inside the tags block.

Help me fix the application route table validation error.

  • Add the database route table below aws_route_table.app by pasting this code:
resource "aws_route_table" "db" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-db"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

Why isolate database routing?

The database subnets receive a separate routing policy for their isolated tier. The policy can remain local to the VPC while other tiers gain different paths.

  • Save network.tf.
  • Validate all three route tables by running:
terraform validate

What does this check prove?

Terraform confirms that the gateway and all three route tables form a valid configuration. The next changes connect those route tables to their subnet tiers.

You should see confirmation that the configuration is valid.

Database route table validation failing?

Check that the resource name is aws_route_table.db. Compare its braces with the valid public route table above it.

Help me fix the database route table validation error.

Connect subnets to their routes

A route table becomes useful after it has routes and subnet associations. The public table gets an internet route while every tier gets stable associations.

  • Add the public internet route below aws_route_table.db by pasting this code:
resource "aws_route" "public_internet" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main.id
}

What does this route do?

  • The destination 0.0.0.0/0 represents IPv4 traffic that does not match a more specific route.
  • The gateway_id reference sends that traffic to the Internet Gateway.
  • The route belongs only to aws_route_table.public.
  • Save network.tf.
  • Validate the public route by running:
terraform validate

What does this check prove?

Terraform confirms that the route references the public route table and Internet Gateway correctly. The configuration is ready for subnet associations.

You should see confirmation that the configuration is valid.

Public route validation failing?

Confirm that route_table_id points to aws_route_table.public.id. Confirm that gateway_id points to aws_internet_gateway.main.id.

Help me fix the public internet route validation error.

  • Associate both public subnets with the public route table by adding this code below aws_route.public_internet:
resource "aws_route_table_association" "public" {
  for_each = local.public_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.public.id
}

How does this create two associations?

The for_each expression creates one association for each entry in local.public_subnets. Each logical key selects the matching subnet from aws_subnet.this.

  • Save network.tf.
  • Validate the public associations by running:
terraform validate

What does this check prove?

Terraform confirms that the filtered public subnet map can drive the associations. Both public subnets now have declared links to the public route table.

You should see confirmation that the configuration is valid.

Public association validation failing?

Confirm that for_each uses local.public_subnets. Check that each.key remains inside the brackets after aws_subnet.this.

Help me fix the public route table associations.

  • Associate both application subnets with the application route table by adding this code below aws_route_table_association.public:
resource "aws_route_table_association" "app" {
  for_each = local.app_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.app.id
}

How is the application tier separated?

These associations connect app-a and app-b to the application route table. They do not inherit the public table's Internet Gateway route.

  • Save network.tf.
  • Validate the application associations by running:
terraform validate

What does this check prove?

Terraform confirms that each application subnet resolves to its own association instance. Both instances point to the shared application route table.

You should see confirmation that the configuration is valid.

Application association validation failing?

Confirm that the filtered map is local.app_subnets. Check that the target is aws_route_table.app.id.

Help me fix the application route table associations.

  • Associate both database subnets with the database route table by adding this code below aws_route_table_association.app:
resource "aws_route_table_association" "db" {
  for_each = local.db_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.db.id
}

How is database isolation declared?

These associations connect both database subnets to aws_route_table.db. That table currently contains only the automatic VPC-local route.

  • Save network.tf.
  • Validate every subnet association by running:
terraform validate

What does this check prove?

Terraform confirms that all six subnets have declared route table associations. The routing layer is now complete enough to plan.

You should see confirmation that the configuration is valid.

Database association validation failing?

Confirm that for_each uses local.db_subnets. Check that the route table reference is aws_route_table.db.id.

Help me fix the database route table associations.

✔️ Awesome, I've got everything!

Great. Double check that you saved network.tf before continuing.

ⓧ I'd like to double check the full code

Compare your cumulative network.tf file with this version:

data "aws_availability_zones" "available" {
  state = "available"

  filter {
    name   = "opt-in-status"
    values = ["opt-in-not-required"]
  }
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = var.project_name
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_subnet" "this" {
  for_each = local.subnets

  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, each.value.netnum)
  availability_zone       = data.aws_availability_zones.available.names[each.value.az_index]
  map_public_ip_on_launch = each.value.tier == "public"

  tags = {
    Name        = "${var.project_name}-${each.key}"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
    Tier        = each.value.tier
  }
}

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

  tags = {
    Name        = "${var.project_name}-igw"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

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

  tags = {
    Name        = "${var.project_name}-public"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

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

  tags = {
    Name        = "${var.project_name}-app"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

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

  tags = {
    Name        = "${var.project_name}-db"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_route" "public_internet" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main.id
}

resource "aws_route_table_association" "public" {
  for_each = local.public_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "app" {
  for_each = local.app_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.app.id
}

resource "aws_route_table_association" "db" {
  for_each = local.db_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.db.id
}

What should match?

Your file should contain the existing Availability Zone data source and subnet resources. It should finish with the new routing resources shown here.

Two new outputs make the private route tables easy to inspect from the terminal.

  • Return to outputs.tf from the previous step.
  • Add these outputs below the existing subnet_ids output:
output "app_route_table_id" {
  description = "Route table used by both private application subnets."
  value       = aws_route_table.app.id
}

output "db_route_table_id" {
  description = "Isolated route table used by both database subnets."
  value       = aws_route_table.db.id
}

What do these outputs expose?

  • The app_route_table_id output exposes the application route table ID.
  • The db_route_table_id output exposes the isolated database route table ID.
  • Both values come from Terraform-managed resources.
  • Save outputs.tf.
  • Apply Terraform's canonical formatting by running:
terraform fmt

What does this command do?

Terraform formats the configuration files using its canonical style. It prints the name of any file whose formatting changed.

  • Validate the complete routing configuration by running:
terraform validate

What does this check prove?

Terraform checks the complete root module for valid syntax. It also verifies internal references before the plan reaches AWS.

You should see confirmation that the configuration is valid.

Final validation failing?

Read the file name and line number in the validation message. Compare that section with the full-file references in the double-check tabs.

Help me debug the final Terraform validation error.

✔️ Awesome, I've got everything!

Great. Double check that you saved outputs.tf before planning the deployment.

ⓧ I'd like to double check the full code

Compare your cumulative outputs.tf file with this version:

output "vpc_id" {
  description = "ID of the network foundation VPC."
  value       = aws_vpc.main.id
}

output "subnet_ids" {
  description = "Subnet IDs keyed by stable logical names."
  value = {
    for name, subnet in aws_subnet.this : name => subnet.id
  }
}

output "app_route_table_id" {
  description = "Route table used by both private application subnets."
  value       = aws_route_table.app.id
}

output "db_route_table_id" {
  description = "Isolated route table used by both database subnets."
  value       = aws_route_table.db.id
}

What should match?

Your file should retain the VPC and subnet outputs from the previous step. It should finish with the two new route table outputs.

Before you run the plan, how many new routing resources do you expect Terraform to propose?

  • Preview the routing changes by running:
terraform plan

What should the plan contain?

The plan should propose eleven additions. These additions cover one gateway, three route tables, one public route, and six subnet associations.

The existing VPC and six subnets should remain unchanged.

Plan shows unexpected changes?

Pause before applying if Terraform proposes replacing the VPC or a subnet. Compare the changed attributes with the cumulative network.tf reference.

Help me review an unexpected Terraform routing plan.

  • Start the routing deployment by running:
  • Enter yes when Terraform asks for approval.
terraform apply

What does this deployment create?

Terraform creates the gateway and route tables in dependency order. It then creates the public route and all six subnet associations.

You should see confirmation that eleven resources were added. The output section should include both route table IDs.

Routing deployment failed?

Keep the local state in place if AWS returns a temporary provisioning error. Run the plan again to see which resources still need to be created.

Help me recover from a failed Terraform routing apply.

That is the routing layer deployed. The public subnets now share a route through the Internet Gateway.

Inspect the private-tier gap

The deployment succeeded, but success does not prove that every subnet has the same reachability. The AWS CLI lets you inspect the application route table directly.

  • Print the application route table ID by running:
terraform output -raw app_route_table_id

What does this command return?

The -raw option prints the scalar route table ID without Terraform's surrounding formatting. You can paste that ID directly into the AWS CLI inspection command.

You should see the AWS ID for the route table associated with app-a and app-b.

Before you inspect the route table, do you think the application tier has a path beyond the VPC?

  • Copy the route table ID printed by Terraform.
  • Inspect that application route table by replacing <paste-the-output-ID> in this command:
aws ec2 describe-route-tables --route-table-ids <paste-the-output-ID>

What does this inspection reveal?

The response includes the route destinations and their targets. It also shows the subnet associations attached to the application route table.

You should see only the VPC-local route. You should not see a NAT route or an S3 prefix-list route.

Why is this gap important?

The local route supports traffic between addresses inside 10.0.0.0/16. It provides no general outbound path for a future private EC2 instance.

A future application instance could not use this route table to download app.jar from S3. It could not reach external package repositories either.

Route table inspection failed?

Confirm that you pasted the ID from terraform output -raw app_route_table_id. Keep your shell authenticated to the same AWS account and Ohio region used by Terraform.

Help me inspect the application route table.

Your public tier now has an internet path, while the application route table exposes a clear reachability gap. Next, you'll add controlled outbound access and a dedicated S3 path without weakening the database tier.

Add Controlled Egress and Tier Boundaries

The network in Amazon Web Services now has an Internet Gateway route for its public subnets. The application subnets still have only the VPC-local route you inspected in the previous step.

A NAT Gateway provides general outbound access for the application tier. An S3 gateway endpoint provides a dedicated route to Amazon S3.

Three security groups will encode the trust path between tiers. Terraform will leave the future ALB edge closed until the application runtime contract exists.

In this step, get ready to:
  • Add controlled outbound paths for the private application subnets.
  • Encode application-to-S3 and application-to-database boundaries.
  • Verify the completed application route table in AWS.
Add controlled outbound paths

General internet traffic needs a public NAT path. S3 traffic can use a regional service route that avoids the NAT Gateway.

Know the NAT cost

This is the point where the project starts using chargeable resources. Creating the NAT Gateway also allocates a public Elastic IP address.

In US East (Ohio), the NAT Gateway costs $0.045 per provisioned hour. NAT traffic costs $0.045 per GB processed.

The public IPv4 address costs $0.005 per hour. The S3 gateway endpoint has no additional charge.

  • Return to network.tf in your code editor.
  • Find the aws_availability_zones data source near the top of the file.
  • Add the S3 endpoint-service lookup directly below that data source:
data "aws_vpc_endpoint_service" "s3" {
  service      = "s3"
  service_type = "Gateway"
}

What does this data source do?

  • The data source asks the AWS provider for the regional S3 service definition.
  • The Gateway service type selects the endpoint category that adds a service route to a route table.
  • The exported service_name value keeps the regional identifier out of your handwritten configuration.
  • Save network.tf.
  • Check the updated configuration by running:
terraform validate

What should I see?

Terraform should confirm that the configuration is valid. This proves the provider recognizes the S3 endpoint-service lookup.

Data source not recognized?

Confirm the block is below the closing brace of the Availability Zones data source. Check that your provider lock file still selects the pinned AWS provider version.

Compare the data source name with aws_vpc_endpoint_service if Terraform reports an unknown reference.

Help me fix the S3 endpoint-service data source in network.tf.

  • Scroll to the end of network.tf.
  • Add the NAT Elastic IP below the database route-table association:
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name        = "${var.project_name}-nat"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }

  depends_on = [aws_internet_gateway.main]
}

What does this resource do?

  • The vpc domain allocates a public IPv4 address for use inside the VPC.
  • The explicit dependency waits for the Internet Gateway before allocating the address.
  • The tags connect the address to the same project identity as the existing network.
  • Save network.tf.
  • Add the NAT Gateway directly below the Elastic IP block:
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.this["public-a"].id

  tags = {
    Name        = "${var.project_name}-nat"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }

  depends_on = [aws_internet_gateway.main]
}

How does the NAT path start?

  • The allocation_id reference attaches the Elastic IP to the NAT Gateway.
  • The subnet_id reference places the gateway in public-a.
  • The dependency ensures the VPC has an Internet Gateway before AWS creates the NAT Gateway.
  • Save network.tf.
  • Preview these two chargeable resources by running:
terraform plan

What should the plan show?

The plan should propose an Elastic IP and a NAT Gateway. Your existing VPC and subnets should remain in place.

A plan only previews the changes. Billing begins after the later apply creates the resources.

NAT resources missing from the plan?

Check that both new resource blocks sit outside the route-table association above them. A misplaced closing brace can nest one block inside another.

Confirm the subnet reference uses public-a exactly. That key already exists in your subnet map.

Help me debug why Terraform is not planning the NAT resources.

  • Add the application default route below the NAT Gateway block:
resource "aws_route" "app_internet" {
  route_table_id         = aws_route_table.app.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.main.id
}

How does this route close the gap?

The route sends destinations outside the VPC through the NAT Gateway. Both application subnets inherit this behavior because they already share the application route table.

  • Save network.tf.
  • Add the S3 gateway endpoint directly below the application route:
resource "aws_vpc_endpoint" "s3" {
  vpc_id          = aws_vpc.main.id
  service_name    = data.aws_vpc_endpoint_service.s3.service_name
  route_table_ids = [aws_route_table.app.id]

  tags = {
    Name        = "${var.project_name}-s3"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

Why add a separate S3 path?

  • The endpoint associates the regional S3 service with the application route table.
  • AWS adds a route for the S3 prefix list to that route table.
  • Same-Region S3 traffic can use this service route without crossing the NAT Gateway.
  • Save network.tf.
  • Validate the completed outbound-path configuration by running:
terraform validate

What did this check prove?

Terraform should confirm that the configuration is valid. The NAT route and S3 endpoint now resolve every referenced resource.

Seeing an unresolved reference?

Check that the data source is named s3 in both its declaration and the endpoint reference. Confirm the application route table remains named aws_route_table.app.

Help me resolve the Terraform references in my NAT route or S3 endpoint.

✔️ Awesome, I've got everything!

Your outbound-path configuration is complete. Double-check that you saved network.tf.

ⓧ I'd like to double check the full code

Compare your complete network.tf file with this reference:

data "aws_availability_zones" "available" {
  state = "available"

  filter {
    name   = "opt-in-status"
    values = ["opt-in-not-required"]
  }
}

data "aws_vpc_endpoint_service" "s3" {
  service      = "s3"
  service_type = "Gateway"
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = var.project_name
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_subnet" "this" {
  for_each = local.subnets

  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(var.vpc_cidr, 8, each.value.netnum)
  availability_zone       = data.aws_availability_zones.available.names[each.value.az_index]
  map_public_ip_on_launch = each.value.tier == "public"

  tags = {
    Name        = "${var.project_name}-${each.key}"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
    Tier        = each.value.tier
  }
}

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

  tags = {
    Name        = "${var.project_name}-igw"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

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

  tags = {
    Name        = "${var.project_name}-public"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

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

  tags = {
    Name        = "${var.project_name}-app"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

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

  tags = {
    Name        = "${var.project_name}-db"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_route" "public_internet" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main.id
}

resource "aws_route_table_association" "public" {
  for_each = local.public_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "app" {
  for_each = local.app_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.app.id
}

resource "aws_route_table_association" "db" {
  for_each = local.db_subnets

  subnet_id      = aws_subnet.this[each.key].id
  route_table_id = aws_route_table.db.id
}

resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name        = "${var.project_name}-nat"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.this["public-a"].id

  tags = {
    Name        = "${var.project_name}-nat"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route" "app_internet" {
  route_table_id         = aws_route_table.app.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.main.id
}

resource "aws_vpc_endpoint" "s3" {
  vpc_id          = aws_vpc.main.id
  service_name    = data.aws_vpc_endpoint_service.s3.service_name
  route_table_ids = [aws_route_table.app.id]

  tags = {
    Name        = "${var.project_name}-s3"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}
Encode tier security boundaries

Routes decide where traffic can travel. Security groups decide which connections the future application and database tiers can make.

The application tier needs HTTPS access to S3 on 443. It also needs access to the future PostgreSQL tier on the validated database port.

  • Create security.tf inside ~/projects/tf-network-foundation with your code editor.
  • Add the future ALB security group to the new file:
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb"
  description = "Future ALB boundary; listener and application-port rules are deferred"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-alb"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

Why is the ALB group empty?

The future application listener port is still unknown. An empty boundary records the intended tier without inventing a load-bearing rule.

  • Save security.tf.
  • Add the application security group below the ALB block:
resource "aws_security_group" "app" {
  name        = "${var.project_name}-app"
  description = "Future private Java application tier"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-app"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

What boundary does this create?

This group represents the future private Java application tier. Standalone egress rules will grant only the two outbound paths the tier currently needs.

  • Save security.tf.
  • Check the first two security group declarations by running:
terraform validate

What should I see?

Terraform should confirm that both groups reference the existing VPC correctly. The empty ALB group should remain valid.

  • Add the database security group below the application group:
resource "aws_security_group" "db" {
  name        = "${var.project_name}-db"
  description = "Future private PostgreSQL tier"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-db"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

What does this group represent?

This group marks the database trust boundary. Its later ingress rule will accept database traffic only when the source carries the application security group.

  • Save security.tf.
  • Add the application-to-S3 egress rule below the database group:
resource "aws_vpc_security_group_egress_rule" "app_to_s3" {
  security_group_id = aws_security_group.app.id
  description       = "Allow HTTPS from the application tier to the regional S3 prefix list"
  prefix_list_id    = aws_vpc_endpoint.s3.prefix_list_id
  from_port         = 443
  ip_protocol       = "tcp"
  to_port           = 443
}

How is S3 access constrained?

  • The rule belongs to the application security group.
  • The endpoint supplies the regional S3 prefix list as the destination.
  • The port range permits HTTPS on TCP 443.
  • Save security.tf.
  • Validate the database boundary and S3 rule by running:
terraform validate

What did this validation connect?

Terraform should resolve the database group and the endpoint prefix list. The application-to-S3 rule is now structurally complete.

Prefix list reference failing?

Confirm the endpoint resource is named aws_vpc_endpoint.s3 in network.tf. Check that the rule uses its prefix_list_id attribute.

Help me fix the application-to-S3 security group rule.

  • Add the application-to-database egress rule below the S3 rule:
resource "aws_vpc_security_group_egress_rule" "app_to_db" {
  security_group_id            = aws_security_group.app.id
  description                  = "Allow PostgreSQL from the application tier to the database tier"
  referenced_security_group_id = aws_security_group.db.id
  from_port                    = var.database_port
  ip_protocol                  = "tcp"
  to_port                      = var.database_port
}

Why reference another group?

The destination is the database security group instead of an IP range. Future database addresses can change without changing this trust relationship.

The validated database_port variable keeps the egress rule aligned with the intended PostgreSQL listener.

  • Save security.tf.
  • Add the matching database ingress rule below the egress rule:
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  description                  = "Allow PostgreSQL from the application tier"
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = var.database_port
  ip_protocol                  = "tcp"
  to_port                      = var.database_port
}

How does the trust path close?

The database accepts TCP traffic on the configured database port only from the application security group. The egress and ingress rules describe both sides of the same connection.

  • Save security.tf.
  • Preview the completed security boundaries by running:
terraform plan

What should the security plan show?

The plan should include three security groups and three standalone security group rules. The ALB group should have no listener rule or application-port rule.

Security rules not planning cleanly?

Confirm each standalone rule sits outside the three security group blocks. Check that both database rules use var.database_port.

Verify that the egress rule references aws_security_group.db.id. Verify that the ingress rule references aws_security_group.app.id.

Help me debug the application and database security group references.

✔️ Awesome, I've got everything!

Your tier boundaries are declared. Double-check that you saved security.tf.

ⓧ I'd like to double check the full code

Compare your complete security.tf file with this reference:

resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb"
  description = "Future ALB boundary; listener and application-port rules are deferred"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-alb"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_security_group" "app" {
  name        = "${var.project_name}-app"
  description = "Future private Java application tier"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-app"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_security_group" "db" {
  name        = "${var.project_name}-db"
  description = "Future private PostgreSQL tier"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-db"
    Project     = var.project_name
    Environment = "learning"
    ManagedBy   = "Terraform"
  }
}

resource "aws_vpc_security_group_egress_rule" "app_to_s3" {
  security_group_id = aws_security_group.app.id
  description       = "Allow HTTPS from the application tier to the regional S3 prefix list"
  prefix_list_id    = aws_vpc_endpoint.s3.prefix_list_id
  from_port         = 443
  ip_protocol       = "tcp"
  to_port           = 443
}

resource "aws_vpc_security_group_egress_rule" "app_to_db" {
  security_group_id            = aws_security_group.app.id
  description                  = "Allow PostgreSQL from the application tier to the database tier"
  referenced_security_group_id = aws_security_group.db.id
  from_port                    = var.database_port
  ip_protocol                  = "tcp"
  to_port                      = var.database_port
}

resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
  security_group_id            = aws_security_group.db.id
  description                  = "Allow PostgreSQL from the application tier"
  referenced_security_group_id = aws_security_group.app.id
  from_port                    = var.database_port
  ip_protocol                  = "tcp"
  to_port                      = var.database_port
}
Publish outputs and deploy

Outputs expose the IDs needed for inspection and later infrastructure. Four new outputs will describe the NAT path, S3 endpoint, and tier security groups.

  • Return to outputs.tf in your code editor.
  • Find the closing brace of the db_route_table_id output.
  • Add the NAT Gateway ID output below it:
output "nat_gateway_id" {
  description = "ID of the cost-conscious single NAT Gateway."
  value       = aws_nat_gateway.main.id
}

What does this output expose?

The output publishes the AWS ID of the managed NAT Gateway. You can use that ID to connect Terraform state with AWS inspection later.

  • Save outputs.tf.
  • Add the NAT public IP output below the Gateway ID output:
output "nat_public_ip" {
  description = "Public IPv4 address used by the NAT Gateway."
  value       = aws_nat_gateway.main.public_ip
}

Why expose the public address?

The output shows the public IPv4 address used for NAT egress. It gives later workloads a concrete address to compare with observed outbound traffic.

  • Save outputs.tf.
  • Validate the first two outputs by running:
terraform validate

What should I see?

Terraform should confirm that both outputs reference attributes exported by the NAT Gateway resource.

  • Add the S3 endpoint ID output below the NAT public IP output:
output "s3_vpc_endpoint_id" {
  description = "ID of the S3 gateway VPC endpoint."
  value       = aws_vpc_endpoint.s3.id
}

What does this endpoint output provide?

The output exposes the managed gateway endpoint ID. That identifier distinguishes the S3 service path from the general NAT path.

  • Save outputs.tf.
  • Add the security group map below the endpoint output:
output "security_group_ids" {
  description = "Security group IDs for the future ALB, application, and database tiers."
  value = {
    alb = aws_security_group.alb.id
    app = aws_security_group.app.id
    db  = aws_security_group.db.id
  }
}

Why return a map?

The map keeps each security group ID attached to a stable tier name. Later infrastructure can select the required boundary without relying on output order.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Your new infrastructure IDs are ready to publish. Double-check that you saved outputs.tf.

ⓧ I'd like to double check the full code

Compare your complete outputs.tf file with this reference:

output "vpc_id" {
  description = "ID of the network foundation VPC."
  value       = aws_vpc.main.id
}

output "subnet_ids" {
  description = "Subnet IDs keyed by stable logical names."
  value = {
    for name, subnet in aws_subnet.this : name => subnet.id
  }
}

output "app_route_table_id" {
  description = "Route table used by both private application subnets."
  value       = aws_route_table.app.id
}

output "db_route_table_id" {
  description = "Isolated route table used by both database subnets."
  value       = aws_route_table.db.id
}

output "nat_gateway_id" {
  description = "ID of the cost-conscious single NAT Gateway."
  value       = aws_nat_gateway.main.id
}

output "nat_public_ip" {
  description = "Public IPv4 address used by the NAT Gateway."
  value       = aws_nat_gateway.main.public_ip
}

output "s3_vpc_endpoint_id" {
  description = "ID of the S3 gateway VPC endpoint."
  value       = aws_vpc_endpoint.s3.id
}

output "security_group_ids" {
  description = "Security group IDs for the future ALB, application, and database tiers."
  value = {
    alb = aws_security_group.alb.id
    app = aws_security_group.app.id
    db  = aws_security_group.db.id
  }
}

Before you run the final plan, which existing resources do you expect Terraform to preserve?

  • Format the files and validate the full configuration by running these commands:
terraform fmt
terraform validate
terraform plan

What should the final plan show?

Terraform should format the changed files and confirm that the configuration is valid. The plan should add the outbound resources, security boundaries, and outputs without replacing the existing VPC.

Review every planned action before continuing. The database route table should remain local-only.

Plan proposing an unexpected replacement?

Stop before applying if Terraform proposes replacing the VPC or any subnet. Compare your existing resource names with the complete file references above.

Check that you added new blocks without changing the existing VPC CIDR or subnet expressions.

Help me understand an unexpected Terraform replacement before I apply.

Terraform apply repeats the plan before waiting for the literal confirmation yes. Check the actions carefully because this apply starts NAT Gateway and public IPv4 billing.

  • Start the interactive deployment by running:
terraform apply

What happens during apply?

Terraform refreshes the existing network and presents the planned additions. It waits for your approval before AWS creates anything.

  • Review the proposed additions in the terminal.
  • Type yes when the plan matches the configuration you reviewed.

AWS may take a few minutes to provision the NAT Gateway. A quiet pause during this resource is expected.

That is the chargeable part complete. Your private application tier now has controlled outbound paths and explicit trust boundaries.

  • Inspect every published network value by running:
terraform output

What should the outputs include?

You should see the existing VPC and subnet outputs. You should also see values for the NAT Gateway, NAT public IP, S3 endpoint, and three security groups.

  • Print the application route table ID by running:
terraform output -raw app_route_table_id

What does raw output change?

The raw option prints only the scalar route table ID. That clean value is ready for the AWS CLI inspection command.

  • Record the printed value here: application route table ID.

Before you inspect the route table, which three route categories do you expect to find?

  • Inspect the deployed application route table by running:
aws ec2 describe-route-tables --route-table-ids [[APP_ROUTE_TABLE_ID="application route table ID"]]

What should I see?

The response should include the VPC-local route. It should also include 0.0.0.0/0 with a NAT Gateway target.

A separate prefix-list route should target the S3 VPC endpoint. That route proves S3 traffic has its purpose-built service path.

Missing a route?

Confirm the apply completed before inspecting the route table. Check that you used the application route table ID instead of the database route table ID.

If the default route is missing, inspect aws_route.app_internet in the Terraform output. If the prefix-list route is missing, inspect aws_vpc_endpoint.s3.

Help me diagnose a missing NAT or S3 route in the application route table.

Your network now has controlled egress and tier-specific trust paths. Next, you will turn these deployed relationships into an editable architecture diagram.

Create an Editable Architecture Diagram

Your NAT path is now deployed in Amazon Web Services. Your S3 endpoint path is live too.

Terraform resource IDs prove that the infrastructure exists. An editable architecture diagram makes the routing decisions clear to another engineer.

In this step, you'll represent the deployed network in diagrams.net. You'll also prove that the picture agrees with your Terraform configuration.

In this step, get ready to:
  • Save the supplied network diagram beside your Terraform files.
  • Compare the diagram with your deployed network outputs.
  • Prove that your configuration matches the resources managed by Terraform.
Save and open the architecture diagram

A .drawio file stores editable shapes inside XML. The supplied file turns your network design into a picture that can evolve with the infrastructure.

  • Switch back to the text editor you used for your Terraform files.
  • Create a file named architecture.drawio beside versions.tf.
  • Confirm that architecture.drawio appears beside your .tf files in the editor sidebar.
  • Select the double-check tab below.
  • Copy the entire supplied XML into architecture.drawio.
  • Save architecture.drawio.

✔️ Awesome, I've got everything!

Great. Double-check that architecture.drawio is saved beside your Terraform files.

ⓧ I'd like to double check the full code

Compare your complete architecture.drawio file with this supplied XML:

<mxfile host="app.diagrams.net">
  <diagram id="network-foundation" name="Network Foundation">
    <mxGraphModel dx="0" dy="0" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900" math="0" shadow="0">
      <root>
        <mxCell id="0"/>
        <mxCell id="1" parent="0"/>
        <mxCell id="internet" value="Internet" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
          <mxGeometry x="40" y="90" width="100" height="60" as="geometry"/>
        </mxCell>
        <mxCell id="s3" value="Amazon S3&#xa;future app.jar" style="shape=cloud;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
          <mxGeometry x="1230" y="330" width="130" height="80" as="geometry"/>
        </mxCell>
        <mxCell id="vpc" value="VPC 10.0.0.0/16 | us-east-2" style="rounded=1;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=none;strokeColor=#6c8ebf;strokeWidth=2;" vertex="1" parent="1">
          <mxGeometry x="170" y="40" width="1030" height="760" as="geometry"/>
        </mxCell>
        <mxCell id="igw" value="Internet Gateway" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
          <mxGeometry x="600" y="80" width="170" height="50" as="geometry"/>
        </mxCell>
        <mxCell id="az-a" value="Availability Zone A" style="rounded=1;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
          <mxGeometry x="210" y="150" width="430" height="560" as="geometry"/>
        </mxCell>
        <mxCell id="az-b" value="Availability Zone B" style="rounded=1;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
          <mxGeometry x="690" y="150" width="430" height="560" as="geometry"/>
        </mxCell>
        <mxCell id="public-a" value="Public subnet A&#xa;10.0.0.0/24&#xa;NAT Gateway + future ALB" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
          <mxGeometry x="250" y="210" width="350" height="90" as="geometry"/>
        </mxCell>
        <mxCell id="public-b" value="Public subnet B&#xa;10.0.1.0/24&#xa;future ALB" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
          <mxGeometry x="730" y="210" width="350" height="90" as="geometry"/>
        </mxCell>
        <mxCell id="app-a" value="Private application subnet A&#xa;10.0.10.0/24&#xa;future EC2 Auto Scaling" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
          <mxGeometry x="250" y="360" width="350" height="90" as="geometry"/>
        </mxCell>
        <mxCell id="app-b" value="Private application subnet B&#xa;10.0.11.0/24&#xa;future EC2 Auto Scaling" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
          <mxGeometry x="730" y="360" width="350" height="90" as="geometry"/>
        </mxCell>
        <mxCell id="db-a" value="Isolated database subnet A&#xa;10.0.20.0/24&#xa;future RDS PostgreSQL" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
          <mxGeometry x="250" y="520" width="350" height="90" as="geometry"/>
        </mxCell>
        <mxCell id="db-b" value="Isolated database subnet B&#xa;10.0.21.0/24&#xa;future RDS PostgreSQL" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
          <mxGeometry x="730" y="520" width="350" height="90" as="geometry"/>
        </mxCell>
        <mxCell id="endpoint" value="S3 Gateway Endpoint&#xa;associated with app route table" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
          <mxGeometry x="1010" y="650" width="170" height="70" as="geometry"/>
        </mxCell>
        <mxCell id="deferred" value="Deferred until app contract:&#xa;ALB listener and ALB-to-app port" style="shape=note;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
          <mxGeometry x="460" y="650" width="250" height="90" as="geometry"/>
        </mxCell>
        <mxCell id="edge-internet" value="0.0.0.0/0" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="internet" target="igw">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-public-a" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="igw" target="public-a">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-public-b" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="igw" target="public-b">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-nat-a" value="NAT egress" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="app-a" target="public-a">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-nat-b" value="single NAT egress" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="app-b" target="public-a">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-db-a" value="TCP 5432" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="app-a" target="db-a">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-db-b" value="TCP 5432" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="app-b" target="db-b">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-app-endpoint-a" value="HTTPS 443" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="app-a" target="endpoint">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-app-endpoint-b" value="HTTPS 443" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="app-b" target="endpoint">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
        <mxCell id="edge-endpoint-s3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=classic;" edge="1" parent="1" source="endpoint" target="s3">
          <mxGeometry relative="1" as="geometry"/>
        </mxCell>
      </root>
    </mxGraphModel>
  </diagram>
</mxfile>

How is the diagram structured?

  • The vertex cells define the VPC boundary, Availability Zones, subnet tiers, gateways, endpoint, and deferred edge.
  • The edge cells connect each network path to its source and destination.
  • The geometry values preserve the layout when diagrams.net opens the file.
  • Go to app.diagrams.net in your browser.
  • Click File in the top menu.
  • Click Open.
  • Select architecture.drawio from ~/projects/tf-network-foundation.

You should see a VPC split across two Availability Zones. The six subnets appear in public, application, and database tiers.

No account sign-in is required. Your local architecture.drawio file remains editable in the browser.

Diagram not opening?

Confirm that the file starts with <mxfile host="app.diagrams.net"> and ends with </mxfile>.

Check that the file is named architecture.drawio with no extra file extension.

Help me fix my diagrams.net file.

Compare the diagram with your network outputs

The picture becomes useful when every path matches a deployed resource. Terraform outputs give you a compact inventory for that comparison.

  • Switch back to the terminal from earlier.
  • Display your network outputs by running:
terraform output

What does this command show?

The output lists your VPC, subnet, route-table, NAT Gateway, endpoint, and security-group values. These values connect the logical diagram to the resources recorded in local state.

You should see the VPC ID plus six subnet IDs. You should also see the NAT Gateway, S3 endpoint, and three security-group IDs.

Outputs missing?

Confirm that your terminal is still inside ~/projects/tf-network-foundation.

If an expected output is absent, compare outputs.tf with the completed file from the previous step.

Help me troubleshoot missing Terraform outputs.

  • Return to the browser tab containing your diagram.
  • Confirm that the VPC label shows 10.0.0.0/16.
  • Confirm that the VPC label shows us-east-2.
  • Verify that each Availability Zone contains one public subnet, one application subnet, and one database subnet.
  • Trace the single NAT path from both application subnets through public-a.
  • Trace the HTTPS 443 path from both application subnets through the S3 gateway endpoint.
  • Trace the TCP 5432 paths from the application tier to the database tier.
  • Find the note that defers the ALB listener and ALB-to-application port.
Connect the picture to Terraform state

Terraform state records the resource addresses managed by this configuration. Comparing those addresses with the diagram reveals whether the picture covers the deployed network.

  • Switch back to your terminal.
  • List the managed resource addresses by running:
terraform state list

What does the state list prove?

The list connects Terraform addresses such as aws_vpc.main and aws_nat_gateway.main to the components in your diagram.

It also shows the six instances created by aws_subnet.this plus the routing and security resources behind the visible paths.

You should see addresses for the VPC, subnets, routing resources, NAT Gateway, S3 endpoint, security groups, and standalone security-group rules.

State list empty?

Confirm that you are inside ~/projects/tf-network-foundation with the local state from your deployment.

Avoid creating a new state file. Return to the directory that contains your existing Terraform files and deployed state.

Help me find my Terraform state.

Before you run the final check, do you expect Terraform to propose any infrastructure changes?

  • Compare your configuration, local state, and deployed resources by running:
terraform plan

What does the final plan prove?

Terraform refreshes the remote objects before comparing them with your configuration and local state. A no-change plan proves that all three currently agree.

Terraform reports no changes. Your diagram now represents a network whose declared configuration matches its deployed state.

Plan showing changes?

Read the proposed action before approving anything. A changed file or an out-of-band AWS update can produce a plan when you expected none.

Keep the plan unapproved while you compare the affected address with your saved Terraform files.

Help me interpret my unexpected Terraform plan.

Well done. Your deployed network now has an editable visual contract that another engineer can compare directly with Terraform state.

Secret mission

Create and Reconcile Configuration Drift

Add a temporary VPC tag directly in AWS. Use Terraform to detect the resulting drift. Reconcile the network without recreating the VPC.

Clean Up Your Resources

Clean Up Your Resources

Your three options cover immediate reuse, a rebuildable pause, or complete removal. The deployed AWS network costs about $0.05 per hour before NAT data processing.

Cost warning

The NAT Gateway costs $0.045 per provisioned hour. Each partial hour is billed as a full hour.

The attached public IPv4 address costs $0.005 per hour. NAT data processing costs another $0.045 per GB.

The Pause path and Delete path use Terraform to remove the deployed network. This stops those infrastructure charges.

Resources you used:

  • One Terraform-managed VPC with six subnets across two Availability Zones.
  • One Internet Gateway serving the public tier.
  • Three route tables for the public, application, and database tiers.
  • Six route-table associations connecting each subnet to its tier route table.
  • Route entries for public internet access, private outbound access, and S3 access.
  • One Elastic IP allocated to the NAT Gateway.
  • One S3 gateway endpoint associated with the application route table.
  • Three security groups with three standalone security group rules.
  • The local ~/projects/tf-network-foundation directory containing the Terraform configuration, .terraform.lock.hcl, local state, and architecture.drawio.

Keep everything running

No changes are required. This option fits an immediate follow-on project that needs the deployed network.

  • Keep the AWS network deployed for the follow-on project.
  • Monitor your AWS bill while the NAT Gateway remains provisioned.
  • Budget about $0.05 per hour before NAT data processing.
  • Retain ~/projects/tf-network-foundation for future Terraform changes.

Pause - I'll come back to this later

This option removes the deployed AWS resources. Your Terraform files, dependency lock file, local state, and architecture diagram remain available for a later rebuild.

  • Preview the complete cloud teardown by running this command:
terraform plan -destroy

What does this command do?

Terraform compares the managed deployment with an empty target configuration. The resulting plan previews every removal without changing AWS.

  • Review the destroy plan for every managed network resource.

Removing the network is deliberate. Terraform keeps every local project artifact available for the next deployment.

Terraform displays a final destroy plan before making changes. It waits for the literal confirmation yes.

  • Start the managed-resource teardown by running this command:
terraform destroy

What does this command do?

Terraform deprovisions every AWS object recorded in the local state. It leaves the files in ~/projects/tf-network-foundation untouched.

  • Review the final destroy plan.
  • Enter yes when Terraform requests confirmation.
  • Wait until the terminal reports Destroy complete!.

That clears the chargeable network while preserving its reusable definition. Your project is ready to rebuild when you return.

  • Confirm that the local state contains no managed resources by running:
terraform state list

What should I see?

Terraform prints no resource addresses when the state contains no managed infrastructure. An empty result confirms the cloud teardown is complete.

Still seeing managed resources?

  • Restore the temporary or federated AWS credentials supplied by your account.
  • Return to the terminal in ~/projects/tf-network-foundation.
  • Run the destroy again after resolving the reported cause.

Help me finish the interrupted Terraform cleanup.

Delete - I don't want to use this again

Complete removal starts with the deployed AWS resources. The local project directory is removed only after Terraform confirms that no managed resources remain.

This deletion is permanent. Keeping the local state until the cloud teardown finishes prevents chargeable resources from being stranded in AWS.

Terraform displays a final destroy plan before making changes. It waits for the literal confirmation yes.

  • Destroy every managed AWS resource by running this command:
terraform destroy

What does this command do?

Terraform deprovisions the VPC plus every managed networking resource recorded in local state. The files in ~/projects/tf-network-foundation remain available during the teardown.

  • Review the final destroy plan.
  • Enter yes when Terraform requests confirmation.
  • Wait until the terminal reports Destroy complete!.

The deployed network is now gone. Its NAT Gateway and public IPv4 address can no longer generate infrastructure charges.

  • Confirm that Terraform manages no remaining resources by running:
terraform state list

What should I see?

Terraform prints no resource addresses after a complete destroy. This empty result proves that removing the local directory cannot orphan a managed cloud resource.

Does the state still list resources?

  • Keep the local state file until Terraform completes the destroy.
  • Restore your AWS credentials if they expired during cleanup.
  • Resolve the resource-specific problem reported by Terraform.

Help me safely complete the remaining Terraform teardown.

Deleting the folder is final. The cloud teardown is complete, so this action cannot strand the network in AWS.

  • Remove ~/projects/tf-network-foundation by running these commands:
cd ~/projects
rm -rf ~/projects/tf-network-foundation

What do these commands do?

  • The first command moves your shell outside the project directory.
  • The second command permanently removes the Terraform files, local state, dependency lock file, initialized provider files, and architecture diagram.

Your shell returns to the prompt after the removal completes. The deployed network and its local project artifacts are now cleared.

Is the folder still present?

  • Check that the directory path is exactly ~/projects/tf-network-foundation.
  • Confirm that your Linux user has permission to remove the directory.

Help me remove the remaining local project directory.

Nice Work!

Nice Work!

You did it! Your version-pinned Terraform configuration now deploys a secure two-Availability-Zone network foundation on AWS.

What you learned:

  • Built a two-Availability-Zone VPC with six subnets arranged into distinct public, application, and database tiers.
  • Configured controlled outbound access through one NAT Gateway. Added private Amazon S3 access through a gateway endpoint. Restricted PostgreSQL traffic with security group references on TCP 5432.
  • Inspected Terraform outputs to verify deployed resource IDs. Mapped the three network tiers in an editable diagrams.net architecture. Marked the unfinished ALB-to-application edge as deferred.
  • Secret Mission: created an out-of-band VPC tag change. Used Terraform to detect configuration drift. Reconciled AWS without recreating the VPC.

Ready to quiz yourself?