Build a Two-AZ VPC with Terraform

Deploy a resilient two-AZ AWS network with Terraform.

Introduction

30 Second Summary

A building can welcome visitors through a public entrance while keeping its private rooms locked. Cloud systems need the same separation when they connect to the internet.

In this project, you will use Terraform to deploy a production-style Amazon VPC across two Availability Zones. The finished network gives future public services an internet path while future Amazon EKS workloads use controlled private egress.

What You'll Build

A live two-zone network you can inspect from your terminal to trace exactly how traffic moves.

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

  • A reusable Terraform configuration that deploys the same two-zone network whenever you need it.
  • A route-table inspection that traces public traffic through an internet gateway. The same check reveals each private subnet's zonal NAT gateway.
  • An EKS-ready security boundary where layered security groups keep future workloads reachable only through the load balancer tier.
  • Secret Mission: Add an Amazon S3 gateway VPC endpoint to both private route tables so future workloads can reach S3 without using NAT gateways.

Before We Start

Before any hands-on work, this step locks in your goal of building a reusable two-AZ AWS network foundation for future Amazon EC2, Amazon EKS, and Amazon RDS projects. You are committing to a brief deployment that gives private workloads controlled outbound routing while keeping them free from direct public exposure.

Verify and Open the Terraform Workspace

Your Terraform configuration eventually gains permission to change resources in a live AWS account. An unchecked identity could send those changes to the wrong account.

Compatible command-line tools keep the deployment workflow predictable. An identity check confirms the destination before any resources exist.

Once those checks pass, you will prepare an empty workspace for the network configuration.

In this step, get ready to:
  • Verify Terraform CLI version 1.16.3 or newer.
  • Confirm AWS CLI v2 is using the intended lab account.
  • Create the empty Terraform workspace in Visual Studio Code.
Verify your CLI versions

Windows PowerShell gives you one place to verify both command-line tools. Start with Terraform because this project requires version 1.16.3 or newer.

  • Press the Windows key to open Windows search.
  • Enter Windows PowerShell in the search field.
  • Press Enter to open Windows PowerShell.
  • Check the installed Terraform CLI version by running this command:
terraform version

What Does This Command Check?

The command prints the installed Terraform CLI version. Use the tabs below to match your result with the required version.

✔️ I see version 1.16.3 or higher

Good news. Your Terraform installation meets the project requirement.

ⓧ I see an older version

Your Terraform installation needs an upgrade before the network configuration can be initialized.

  • Visit the official Terraform installation page.
  • Install Terraform version 1.16.3 or newer by following the Windows guidance.
  • Restart Windows PowerShell after the upgrade.
  • Repeat the Terraform version check from above.

ⓧ PowerShell cannot find Terraform

PowerShell cannot locate a Terraform executable through your system path.

  • Visit the official Terraform installation page.
  • Install Terraform version 1.16.3 or newer by following the Windows guidance.
  • Restart Windows PowerShell after the installation.
  • Repeat the Terraform version check from above.

The next check confirms which major version of the AWS CLI is available.

  • Check the installed AWS CLI version by running this command:
aws --version

What Should the Version Show?

The output identifies the installed AWS CLI release. This project uses AWS CLI v2.

✔️ I see AWS CLI v2

That version check is complete. Your AWS CLI is ready to inspect the active identity.

ⓧ I see an older major version

Your installed AWS CLI major version needs an upgrade before you continue.

  • Visit the official AWS CLI installation guide.
  • Install AWS CLI v2 by following the Windows guidance.
  • Restart Windows PowerShell after the upgrade.
  • Repeat the AWS CLI version check from above.

ⓧ PowerShell cannot find the AWS CLI

PowerShell cannot locate the AWS CLI through your system path.

  • Visit the official AWS CLI installation guide.
  • Install AWS CLI v2 by following the Windows guidance.
  • Restart Windows PowerShell after the installation.
  • Repeat the AWS CLI version check from above.

Still Seeing a CLI Problem?

A fresh PowerShell session reloads the commands available through your system path. Restart PowerShell after installing or upgrading either tool.

If a version check still fails, help me diagnose the CLI setup.

Confirm the active AWS identity

The AWS identity controls which account receives future network changes. This check only reads identity metadata from your active credentials.

This is the point to catch a personal account or production role before Terraform can create anything.

Before you run the check, picture the account plus role you expect the command to return.

  • Inspect the active AWS identity by running this command:
aws sts get-caller-identity

What Does the Identity Check Return?

  • The UserId value identifies the caller behind the current session.
  • The Account value identifies the active AWS account.
  • The Arn value identifies the active user or role.
  • Compare the returned Account value with your intended lab account.
  • Compare the returned Arn value with your expected user or role.

That is the high-risk check handled. Your future deployment now has a confirmed destination.

Identity Missing or Unexpected?

Stop here if the command fails. Also stop if either identity value points to the wrong account.

Restore the active credentials for your intended lab account through your usual AWS CLI sign-in process. Repeat the identity check before continuing.

If the values still look wrong, help me identify the active AWS CLI credentials.

Create the workspace files

A dedicated workspace keeps this network configuration separate from other Terraform projects. The folder lives at $HOME\two-az-network.

  • Prepare the project workspace by running these commands:
New-Item -Path "$HOME\two-az-network" -ItemType Directory -Force
Set-Location -Path "$HOME\two-az-network"

What Do These Commands Do?

  • The first command creates the two-az-network directory inside your Windows home folder.
  • The second command changes the current PowerShell location to that directory.
  • The -Force option safely returns the existing directory if it is already present.

PowerShell lists the directory after creating it. Your prompt should now show that commands run from the two-az-network workspace.

Workspace Path Not Showing?

Check that the quoted path matches the command above. Confirm that your Windows account can create a directory inside its home folder.

If the prompt still shows another location, help me troubleshoot the workspace path.

Visual Studio Code opens the current PowerShell folder as a workspace. The next command launches a desktop window for two-az-network.

  • Open the current workspace in Visual Studio Code by running this command:
code .

What Does This Command Open?

The command opens the current PowerShell folder in Visual Studio Code. The Explorer displays two-az-network as the workspace folder.

You should see an empty two-az-network workspace in the Visual Studio Code window.

Workspace Did Not Open?

Restart Windows PowerShell to reload the Visual Studio Code command-line integration. Return to $HOME\two-az-network before running the workspace command again.

If the workspace still does not open, help me diagnose the Visual Studio Code command.

The workspace starts with five empty files. Each file gains one clear responsibility as you build the network.

How Will the Files Be Used?

  • The terraform.tf file holds the Terraform plus provider version constraints.
  • The providers.tf file configures the AWS provider.
  • The variables.tf file defines reusable input values.
  • The network.tf file holds the network resources.
  • The outputs.tf file exposes useful deployment details.
  • Select Explorer in the Visual Studio Code Activity Bar.
  • Create terraform.tf through the New File... button in the Explorer.
  • Create providers.tf through the New File... button in the Explorer.
  • Create variables.tf through the New File... button in the Explorer.
  • Create network.tf through the New File... button in the Explorer.
  • Create outputs.tf through the New File... button in the Explorer.

You should now see all five files beneath two-az-network in the Explorer. Each file should be empty.

Missing a Workspace File?

Confirm that the Explorer heading shows two-az-network. Create any missing file again with the exact spelling shown above.

If the files appear in another folder, help me compare the PowerShell location with the VS Code workspace.

✔️ Awesome, I've got everything!

Well done. Your blank Terraform workspace is ready for the first network configuration.

ⓧ I'd like to double check the full code

The workspace contains terraform.tf, providers.tf, variables.tf, network.tf, plus outputs.tf. Every file has zero lines of content at this checkpoint.

Before the final check, predict whether both commands will confirm the same setup you just prepared.

  • Verify the Terraform version plus active AWS identity by running these commands:
terraform version
aws sts get-caller-identity

What Does the Final Check Prove?

The first command proves that Terraform CLI version 1.16.3 or newer remains available in this PowerShell session.

The second command proves that the intended Account plus Arn remain active before any deployment begins.

You should see Terraform CLI version 1.16.3 or newer. You should also see the intended lab account plus role in the AWS identity response.

Final Check Does Not Match?

Pause before the next step if either command returns an unexpected result. Repeat the matching version or identity fix above.

If you cannot isolate the mismatch, help me verify the final workspace setup.

Your setup is verified. Next, you will turn this blank workspace into a deployed VPC skeleton.

Deploy the Two-AZ VPC Skeleton

Your Terraform tools are verified. Your AWS identity points to the intended lab account.

A complete network introduces gateways plus routes plus security boundaries. This step creates the fastest visible slice by deploying an Amazon VPC with four subnets first.

In this step, get ready to:
  • Pin the Terraform CLI requirement plus the Terraform AWS Provider version.
  • Define the VPC plus four subnets across two Availability Zones.
  • Deploy the skeleton plus inspect its Terraform outputs.
Pin the Terraform toolchain

Version constraints keep the configuration tied to the toolchain it was written for. The provider configuration also sets the AWS Region plus shared tags for every supported resource.

  • Switch back to the empty terraform.tf file in VS Code.
  • Define the Terraform requirement plus provider source by pasting this configuration:
terraform {
  required_version = ">= 1.16.3"

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

How does version pinning help?

  • The required_version constraint requires Terraform CLI 1.16.3 or newer.
  • The source value tells Terraform to install the hashicorp/aws provider.
  • The version value pins the provider to 6.65.0 for reproducible behavior.
  • Save terraform.tf.

✔️ Awesome, I've got everything!

Your Terraform requirement plus provider pin are saved.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.16.3"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.65.0"
    }
  }
}
  • Switch to the empty providers.tf file.
  • Configure the AWS provider plus default resource tags by pasting this configuration:
provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = "lab"
      ManagedBy   = "Terraform"
      Project     = var.project_name
    }
  }
}

What does the provider configure?

  • The region argument reads the deployment Region from var.aws_region.
  • The default_tags block applies the lab environment plus management metadata to supported resources.
  • The Project tag reads its value from var.project_name.

The first initialization can take a little longer while Terraform downloads the pinned provider. A short wait here is expected.

  • Save providers.tf.
  • Initialize the workspace from the PowerShell terminal by running:
terraform init

What does initialization do?

Terraform prepares the workspace for provider-backed operations. It installs the pinned provider plus creates the local dependency lock information.

Initialization failed?

Confirm that PowerShell is still inside $HOME\two-az-network.

Check that the provider source plus version match terraform.tf exactly.

Still blocked? Help me troubleshoot Terraform initialization in my Windows workspace.

✔️ Awesome, I've got everything!

Your provider configuration is saved. Terraform can now initialize the AWS provider.

ⓧ I'd like to double check the full code

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = "lab"
      ManagedBy   = "Terraform"
      Project     = var.project_name
    }
  }
}
Define the VPC skeleton

Input variables keep the Region plus project naming plus VPC address range in one place. The network resources can reuse these values without duplicating them.

  • Switch to the empty variables.tf file.
  • Define the AWS Region input by pasting this block:
variable "aws_region" {
  type        = string
  description = "AWS Region for the network lab."
  default     = "us-east-2"
}

What does this variable control?

The aws_region variable places the lab in US East (Ohio). Its default value is us-east-2.

  • Save variables.tf.
  • Append the project name variable with one blank line above it:
variable "project_name" {
  type        = string
  description = "Name prefix used for network resources."
  default     = "two-az-network"
}

What does the project name control?

The project_name variable supplies a consistent name prefix. It also provides the value for the default project tag.

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

What does validation check?

Terraform checks whether the current configuration is internally consistent. A successful result confirms that the provider can now resolve both variables it references.

Variable validation failed?

Check the braces plus quotation marks in variables.tf.

Confirm that aws_region plus project_name match the references in providers.tf.

Need another set of eyes? Help me fix my Terraform variable validation error.

  • Append the VPC address range variable with one blank line above it:
variable "vpc_cidr" {
  type        = string
  description = "IPv4 CIDR block for the VPC."
  default     = "10.0.0.0/16"
}

What does the VPC CIDR define?

The vpc_cidr variable defines the full private IPv4 range for this network. Each subnet receives a smaller range inside 10.0.0.0/16.

  • Save variables.tf.
  • Validate the completed input definitions by running:
terraform validate

What does this result prove?

A successful validation confirms that all three typed variables are readable. The network resources can now reference them safely.

✔️ Awesome, I've got everything!

Your Region plus project name plus VPC address range are defined.

ⓧ I'd like to double check the full code

variable "aws_region" {
  type        = string
  description = "AWS Region for the network lab."
  default     = "us-east-2"
}

variable "project_name" {
  type        = string
  description = "Name prefix used for network resources."
  default     = "two-az-network"
}

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

The skeleton uses the first two standard Availability Zones available to your account. Spreading the subnets across two zones creates the placement foundation for resilient services.

  • Switch to the empty network.tf file.
  • Query the available standard zones by pasting this data source:
data "aws_availability_zones" "available" {
  state = "available"

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

How are the zones selected?

The data source asks AWS for available zones. The filter excludes zones that require an explicit opt-in.

  • Save network.tf.
  • Append the VPC resource with one blank line above it:
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = var.project_name
  }
}

What does the VPC establish?

  • The cidr_block argument assigns the full network range from var.vpc_cidr.
  • DNS support allows resources in the VPC to resolve names.
  • DNS hostnames prepare the VPC for future services that depend on resolvable node names.
  • Save network.tf.
  • Validate the zone query plus VPC resource by running:
terraform validate

What has Terraform checked?

Terraform confirms that the zone data source plus VPC resource use supported arguments. It also verifies the reference to var.vpc_cidr.

VPC configuration invalid?

Confirm that vpc_cidr exists in variables.tf.

Check that the zone filter uses the exact names shown in the code block.

Still seeing a validation problem? Help me troubleshoot my Availability Zones data source and VPC resource.

The two public subnet ranges reserve space for future internet-facing resources. Their discovery tags prepare them for public load balancers in a future Amazon EKS project.

  • Append the first public subnet with one blank line above it:
resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-a"
    "kubernetes.io/role/elb" = "1"
  }
}

How is public subnet A defined?

The subnet uses 10.0.0.0/24 in the first selected Availability Zone. Automatic public IPv4 assignment remains disabled.

The kubernetes.io/role/elb tag marks the subnet for future public load balancer discovery.

  • Save network.tf.
  • Append the second public subnet with one blank line above it:
resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-b"
    "kubernetes.io/role/elb" = "1"
  }
}

How does public subnet B differ?

The second public subnet uses 10.0.1.0/24 in the next selected Availability Zone. Its name plus discovery tag identify the same public tier.

  • Save network.tf.
  • Validate both public subnet definitions by running:
terraform validate

What does the public-tier check prove?

Terraform can resolve both subnet references to the VPC plus Availability Zones. The configuration now describes two distinct public subnet ranges.

Public subnet validation failed?

Confirm that public subnet A uses names[0].

Confirm that public subnet B uses names[1].

Need help finding the mismatch? Help me compare my two public subnet resources.

The private subnet ranges leave a clear address gap from the public tier. Their discovery tags reserve them for future internal load balancers.

  • Append the first private subnet with one blank line above it:
resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name                              = "${var.project_name}-private-a"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

How is private subnet A defined?

The first private subnet uses 10.0.10.0/24 in the same zone as public subnet A. The kubernetes.io/role/internal-elb tag identifies its internal role.

  • Save network.tf.
  • Append the second private subnet with one blank line above it:
resource "aws_subnet" "private_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name                              = "${var.project_name}-private-b"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

How does private subnet B complete the layout?

The second private subnet uses 10.0.11.0/24 in the same zone as public subnet B. This gives each selected zone one public subnet plus one private subnet.

  • Save network.tf.
  • Validate the complete VPC skeleton by running:
terraform validate

What does the skeleton validation prove?

Terraform can resolve the dependency path from every subnet to the VPC plus zone data source. The configuration is ready for a deployment plan.

Private subnet validation failed?

Check that private subnet A uses 10.0.10.0/24.

Check that private subnet B uses 10.0.11.0/24.

Still stuck? Help me troubleshoot my complete network.tf skeleton.

✔️ Awesome, I've got everything!

Your VPC plus four subnet resources are ready to deploy.

ⓧ I'd like to double check the full code

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_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = var.project_name
  }
}

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-a"
    "kubernetes.io/role/elb" = "1"
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-b"
    "kubernetes.io/role/elb" = "1"
  }
}

resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name                              = "${var.project_name}-private-a"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

resource "aws_subnet" "private_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name                              = "${var.project_name}-private-b"
    "kubernetes.io/role/internal-elb" = "1"
  }
}
Deploy and inspect the skeleton

Outputs turn resource attributes into visible checkpoints. They let you inspect the selected zones plus generated AWS IDs without reading local state directly.

  • Switch to the empty outputs.tf file.
  • Expose the two selected zones by pasting this output:
output "availability_zones" {
  value = [
    data.aws_availability_zones.available.names[0],
    data.aws_availability_zones.available.names[1]
  ]
}

Why output the zones?

The output reveals the exact two zones returned for your AWS account. This provides visible proof that the subnet layout spans two locations.

  • Save outputs.tf.
  • Append the VPC identifier output with one blank line above it:
output "vpc_id" {
  value = aws_vpc.main.id
}

Why expose the VPC ID?

AWS assigns the VPC ID during deployment. Later commands can use this output to inspect only the route tables that belong to this network.

  • Save outputs.tf.
  • Validate the zone plus VPC outputs by running:
terraform validate

What has this validation checked?

Terraform confirms that both outputs reference attributes available from the current configuration. Their values become visible after deployment.

Output references invalid?

Confirm that the zone output references data.aws_availability_zones.available.

Confirm that the VPC output references aws_vpc.main.id.

Need help tracing the references? Help me fix my Terraform output references.

  • Append the public subnet identifiers output with one blank line above it:
output "public_subnet_ids" {
  value = [aws_subnet.public_a.id, aws_subnet.public_b.id]
}

What does the public subnet output collect?

The output groups both public subnet IDs into one list. Future infrastructure can consume the pair without repeating each resource reference.

  • Save outputs.tf.
  • Append the private subnet identifiers output with one blank line above it:
output "private_subnet_ids" {
  value = [aws_subnet.private_a.id, aws_subnet.private_b.id]
}

What does the private subnet output collect?

The final output groups both private subnet IDs. Together the two lists make the public plus private tiers easy to inspect.

  • Save outputs.tf.
  • Validate all four outputs by running:
terraform validate

What does the completed output check prove?

Terraform can resolve all four output expressions. The configuration now exposes every checkpoint required for this skeleton.

✔️ Awesome, I've got everything!

Your VPC plus Availability Zone plus subnet outputs are complete.

ⓧ I'd like to double check the full code

output "availability_zones" {
  value = [
    data.aws_availability_zones.available.names[0],
    data.aws_availability_zones.available.names[1]
  ]
}

output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_ids" {
  value = [aws_subnet.public_a.id, aws_subnet.public_b.id]
}

output "private_subnet_ids" {
  value = [aws_subnet.private_a.id, aws_subnet.private_b.id]
}

The final workflow formats every file. It then refreshes initialization plus validates the configuration before showing the proposed AWS changes.

  • Format plus initialize plus validate plus plan the complete configuration by running these commands:
terraform fmt
terraform init
terraform validate
terraform plan

What does this workflow prove?

  • The format command applies Terraform's standard layout to the configuration files.
  • The initialization command confirms that the workspace has its required provider.
  • The validation command checks the completed configuration structure.
  • The plan command previews one VPC plus four subnet resources before AWS changes.

The plan does not show five resources?

Compare each saved file with its full-code tab. An unsaved resource block cannot appear in the plan.

Confirm that PowerShell is still inside $HOME\two-az-network.

Still seeing an unexpected plan? Help me diagnose my Terraform plan before I apply it.

Before you deploy, predict whether Terraform can determine the subnet creation order from the resource references.

Terraform pauses after presenting the plan. It waits for your approval before creating anything in AWS.

  • Start the deployment by running:
  • Review the proposed actions when Terraform pauses.
  • Approve the plan when Terraform asks for confirmation.
terraform apply

What happens during apply?

Terraform follows the dependency graph from the zone data source to the VPC. It then creates the four subnets inside that VPC.

The completed apply also creates local Terraform state. That state records the relationship between your configuration plus the deployed AWS resources.

That is the first live slice complete. Terraform reports that the VPC plus four subnets were created.

Apply failed or stalled?

Confirm that your active AWS credentials still target the intended lab account.

Review the first failed resource in the apply output for a permissions issue or an unavailable subnet range.

Need help reading the failure? Help me troubleshoot my Terraform apply without exposing credentials.

Before you inspect the result, predict whether both subnet tiers use the same two Availability Zones.

  • Display the deployed Availability Zones by running:
terraform output availability_zones

What should you see?

You should see two distinct Availability Zone names. Those values prove that public subnet A plus private subnet A share one zone while the B pair shares another.

Availability Zones output missing?

Confirm that the apply completed successfully before reading the output.

Check that outputs.tf defines availability_zones exactly.

Still missing the output? Help me trace my Terraform availability_zones output.

Your two-zone VPC skeleton is live plus recorded in Terraform state. Next, you will add explicit routing so the difference between public plus private traffic paths becomes visible.

Route the Public Tier

Your Terraform configuration now creates an Amazon VPC with four subnets across two Availability Zones. The deployed network has its address ranges and DNS settings in place.

Subnet placement alone does not create a traffic path. This step gives the public subnets a default route while leaving both private route tables with only their VPC-local route, which exposes the private-tier limitation through an AWS CLI inspection.

In this step, get ready to:
  • Connect the VPC to an internet gateway.
  • Associate every subnet with an explicit route table.
  • Inspect the public route and the intentional private routing gap.
Connect the public tier

An internet gateway gives the VPC a target for internet traffic. A route table then decides which destinations use that target.

  • Switch back to network.tf in your existing VS Code workspace.
  • Add the internet gateway after the aws_subnet.private_b resource by copying this block:
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-igw"
  }
}

What does this resource do?

  • The vpc_id argument attaches the internet gateway to aws_vpc.main.
  • The Name tag makes the gateway identifiable when you inspect the VPC.
  • Add the public route table directly below the internet gateway by copying this block:
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-public"
  }
}

How does the public table help?

The aws_route_table.public resource creates one shared routing policy for both public subnets. Its routes stay explicit in Terraform state.

  • Save network.tf.
  • Check the first two routing resources by running this command:
terraform validate

Terraform checks the references in both resource blocks. You should see confirmation that the configuration is valid.

Seeing a validation problem?

Check that both blocks sit outside the closing brace of aws_subnet.private_b. Confirm that the resource labels match the snippets exactly.

Still stuck? Help me diagnose the gateway or route-table validation problem.

A route table needs an explicit destination before it can direct internet-bound traffic. The public default route sends every address outside the VPC toward the attached gateway.

  • Add the public default route below aws_route_table.public by copying this block:
resource "aws_route" "public_default" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.main.id
}

How does the default route work?

  • The destination 0.0.0.0/0 matches IPv4 traffic that has no more specific route.
  • The gateway_id sends that traffic through aws_internet_gateway.main.
  • Associate aws_subnet.public_a with the public route table by copying this block below the route:
resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

What does this association control?

The association applies the public table to public_a. Traffic from that subnet now follows the table's explicit routes.

  • Save network.tf.
  • Check the route and first association by running this command:
terraform validate

Terraform checks that the route references the gateway. It also checks that the association references an existing subnet and route table.

Route references not validating?

Compare aws_route_table.public.id with the public table label above it. Check that aws_internet_gateway.main.id matches the gateway resource.

Need another pair of eyes? Help me trace the invalid Terraform reference.

  • Associate aws_subnet.public_b with the same public route table by copying this block below the first association:
resource "aws_route_table_association" "public_b" {
  subnet_id      = aws_subnet.public_b.id
  route_table_id = aws_route_table.public.id
}

Why share one public table?

Both public subnets need the same default route to the internet gateway. The two association resources make that shared policy visible for each subnet.

  • Save network.tf.
  • Confirm that both public associations are valid by running this command:
terraform validate

You should see confirmation that the configuration is valid. Both public subnets now reference the same public table in the configuration.

Second association not validating?

Check that this block uses aws_subnet.public_b.id as its subnet. Keep aws_route_table.public.id as the route-table reference.

Still seeing a problem? Help me compare the two public associations.

Give private subnets explicit tables

Each private subnet gets its own route table so the two Availability Zones can receive separate outbound paths later. For now, neither private table receives a default route.

  • Add the first private route table below the public associations by copying this block:
resource "aws_route_table" "private_a" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-private-a"
  }
}

What does the first private table represent?

The aws_route_table.private_a resource holds the routing policy for the first private subnet. Its distinct identity lets you keep future egress inside the same Availability Zone.

  • Add the second private route table directly below the first one by copying this block:
resource "aws_route_table" "private_b" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-private-b"
  }
}

Why create a second table?

The aws_route_table.private_b resource gives the second private subnet an independent routing policy. This keeps the topology ready for an Availability Zone-local egress path.

  • Save network.tf.
  • Check both private route-table resources by running this command:
terraform validate

Terraform checks both tables against aws_vpc.main. You should see confirmation that the configuration is valid.

Private tables not validating?

Check that the resource labels are private_a and private_b. Confirm that each table uses aws_vpc.main.id.

Need help finding the mismatch? Help me compare the private route tables.

A route-table resource only affects a subnet after an association connects them. These final two associations preserve the Availability Zone pairing established by the subnet configuration.

  • Associate aws_subnet.private_a with aws_route_table.private_a by copying this block:
resource "aws_route_table_association" "private_a" {
  subnet_id      = aws_subnet.private_a.id
  route_table_id = aws_route_table.private_a.id
}

What does this pairing establish?

The first private subnet now uses its own private route table. The table currently provides only the VPC-local path.

  • Associate aws_subnet.private_b with aws_route_table.private_b by copying this block below the first private association:
resource "aws_route_table_association" "private_b" {
  subnet_id      = aws_subnet.private_b.id
  route_table_id = aws_route_table.private_b.id
}

What completes the private layout?

The second association gives private_b its own table. Both private subnets now have explicit routing boundaries.

  • Save network.tf.
  • Check all four subnet associations by running this command:
terraform validate

You should see confirmation that the configuration is valid. The public subnets share one table while each private subnet has its own table.

Private associations not validating?

Check that each subnet suffix matches its route-table suffix. Confirm that the private_a association contains only private_a references.

Still stuck? Help me verify the private subnet pairings.

Your routing configuration is complete for this step. Use the reference below to compare the cumulative contents of network.tf before applying it.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

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_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = var.project_name
  }
}

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-a"
    "kubernetes.io/role/elb" = "1"
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-b"
    "kubernetes.io/role/elb" = "1"
  }
}

resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name                              = "${var.project_name}-private-a"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

resource "aws_subnet" "private_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name                              = "${var.project_name}-private-b"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

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

  tags = {
    Name = "${var.project_name}-igw"
  }
}

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

  tags = {
    Name = "${var.project_name}-public"
  }
}

resource "aws_route" "public_default" {
  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_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

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

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

  tags = {
    Name = "${var.project_name}-private-a"
  }
}

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

  tags = {
    Name = "${var.project_name}-private-b"
  }
}

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

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

Compare this reference with your file from top to bottom. It contains the existing VPC and subnet resources plus the gateway and route-table resources added in this step.

Apply and inspect the routing gap

Terraform has validated the resource graph locally. Applying the configuration now creates the gateway and routing resources in the same VPC as your existing subnets.

Before you run these commands, which existing resources do you expect Terraform to leave unchanged?

  • Format the configuration and deploy the routing changes by running these commands:
terraform fmt
terraform validate
terraform apply

What happens during this workflow?

  • The format command normalizes the layout of the Terraform files.
  • The validation command checks the complete configuration before deployment.
  • The apply command presents the proposed routing changes for your approval.
  • Review the proposed additions when Terraform presents the plan.
  • Approve the apply when the resource list contains the gateway and routing resources from this step.

Terraform should report that the routing resources were added while the existing VPC and subnets remained deployed. Your public tier now has an explicit path through the internet gateway.

Apply did not complete?

Check the first reported resource for a malformed reference or an AWS permission issue. Keep the existing VPC and subnet blocks unchanged while you correct the new routing block.

Need help reading the failure? Help me troubleshoot the Terraform apply.

The route-table inspection needs the deployed VPC ID as a filter value. A PowerShell variable keeps that generated ID available for the next command.

  • Capture the deployed VPC ID by running this command:
$VpcId = terraform output -raw vpc_id

PowerShell now holds the raw vpc_id output in $VpcId for the route-table filter.

Before you inspect the tables, which network tier do you expect to show a default route?

  • Inspect every route table in the deployed VPC by running this command:
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VpcId" --region us-east-2 --output table

You should see the public table route 0.0.0.0/0 through an igw- target. Each private table shows only its VPC-local route with no default route.

Why is the private gap important?

The public route proves that internet-bound traffic has an explicit gateway target. The private tables currently have no route for destinations outside the VPC.

That missing private default route is the intended shortfall. The next step gives each Availability Zone a controlled outbound path.

Route-table output looks different?

Confirm that $VpcId came from the current workspace. Check that the command uses the configured Region us-east-2.

If the public default route is missing, compare aws_route.public_default with the full-code reference. Help me interpret my route-table output.

You have made the public path visible while preserving the private routing gap. Next, you will add one Availability Zone-local outbound path for each private subnet and create the network's security boundaries.

Add Resilient Egress and Security Boundaries

Your Terraform network now sends public-subnet traffic through an internet gateway. The private route tables still contain only their VPC-local routes.

This step gives each Availability Zone its own outbound path. You will also add security boundaries that limit future workloads to traffic from the load balancer tier.

In this step, get ready to:
  • Add two zonal NAT gateways with dedicated Elastic IP addresses.
  • Route each private subnet through the NAT gateway in its Availability Zone.
  • Add layered security groups before deploying the completed network.
Add zonal NAT gateways

A public NAT gateway lets private workloads initiate outbound connections without accepting unsolicited inbound internet traffic. Each gateway needs an Elastic IP address plus a public subnet.

Why use one gateway per zone?

Each private subnet uses the NAT gateway in the same Availability Zone. This keeps either zone from depending on the other zone for outbound traffic.

The paired design makes the routing relationship visible in your Terraform configuration. It also avoids a single zonal egress dependency.

  • In network.tf, find the closing brace of aws_route_table_association.private_b.
  • Add the two Elastic IP resources below that association by pasting this code:
resource "aws_eip" "nat_a" {
  domain = "vpc"

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

  depends_on = [aws_internet_gateway.main]
}

resource "aws_eip" "nat_b" {
  domain = "vpc"

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

  depends_on = [aws_internet_gateway.main]
}

What do these resources do?

  • The domain value allocates each address for use inside a VPC.
  • The tags identify which address belongs to each zonal NAT gateway.
  • The explicit dependencies keep both allocations behind the existing internet gateway in Terraform's dependency graph.
  • Save network.tf.
  • Check the updated configuration by running this command in the PowerShell terminal from earlier:
terraform validate

What does this check prove?

Terraform checks that both Elastic IP resources use valid references. A successful validation confirms that the configuration remains internally consistent.

Seeing a validation error?

Check that both resources sit outside the route-table association block. A misplaced closing brace can make Terraform treat them as nested configuration.

Confirm that both depends_on values reference aws_internet_gateway.main exactly.

Still stuck? Help me troubleshoot my Elastic IP resources.

The addresses now exist in the configuration. The next resources pair each address with the public subnet in the same zone.

  • Add the two NAT gateway resources below aws_eip.nat_b by pasting this code:
resource "aws_nat_gateway" "a" {
  allocation_id = aws_eip.nat_a.allocation_id
  subnet_id     = aws_subnet.public_a.id

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

  depends_on = [aws_internet_gateway.main]
}

resource "aws_nat_gateway" "b" {
  allocation_id = aws_eip.nat_b.allocation_id
  subnet_id     = aws_subnet.public_b.id

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

  depends_on = [aws_internet_gateway.main]
}

How are the gateways paired?

  • Gateway a uses the allocation from nat_a inside public_a.
  • Gateway b uses the allocation from nat_b inside public_b.
  • The dependencies ensure that the internet gateway exists before Terraform creates either NAT gateway.
  • Save network.tf.
  • Validate the gateway references by running the first command below:
  • Preview the four planned egress resources by running the second command below:
terraform validate
terraform plan

What should the plan show?

The validation should complete successfully. The plan should include two Elastic IP addresses plus two NAT gateways without changing the deployed subnets.

NAT gateway references not valid?

Check that aws_nat_gateway.a uses aws_eip.nat_a.allocation_id plus aws_subnet.public_a.id.

Check that the b resource uses the matching nat_b allocation plus public_b subnet.

Need another pair of eyes? Help me trace my NAT gateway dependencies.

Route each private subnet through its local gateway

The NAT gateways define possible outbound targets. Each private route table now needs a default route to the gateway in the matching zone.

  • Add the private default routes below aws_nat_gateway.b in network.tf by pasting this code:
resource "aws_route" "private_a_default" {
  route_table_id         = aws_route_table.private_a.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.a.id
}

resource "aws_route" "private_b_default" {
  route_table_id         = aws_route_table.private_b.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.b.id
}

What do these routes change?

  • The private_a route table sends default traffic to aws_nat_gateway.a.
  • The private_b route table sends default traffic to aws_nat_gateway.b.
  • Both private subnets retain their separate route tables. Each zone therefore keeps its own outbound path.
  • Save network.tf.
  • Validate the new route references by running the first command below:
  • Preview the two private route additions by running the second command below:
terraform validate
terraform plan

What should Terraform detect?

Terraform should accept both route resources. The plan should add one private default route per Availability Zone.

Private routes pointing to the wrong targets?

Match aws_route_table.private_a.id with aws_nat_gateway.a.id.

Match aws_route_table.private_b.id with aws_nat_gateway.b.id.

Still seeing a route error? Help me inspect my private default routes.

Add security boundaries and deploy the network

A security group controls which traffic can reach future resources. Separate load balancer plus workload groups create a clear boundary between public HTTP traffic and application traffic.

  • Add the load balancer plus workload security groups below aws_route.private_b_default in network.tf by pasting this code:
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb"
  description = "Public HTTP entry point for a future load balancer"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-alb"
  }
}

resource "aws_security_group" "workload" {
  name        = "${var.project_name}-workload"
  description = "Application traffic accepted only from the load balancer security group"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-workload"
  }
}

Why define two groups?

The alb group represents the public entry layer. The workload group represents future application targets.

Separate groups let the application layer trust the load balancer group as a source. Future workload addresses do not need to be listed individually.

  • Save network.tf.
  • Check both security-group resources by running this command:
terraform validate

What does this validation cover?

Terraform checks that both groups reference the existing VPC. It also confirms that their resource blocks have valid structure.

Security groups not validating?

Confirm that both vpc_id values reference aws_vpc.main.id.

Check that each tags block has its own closing brace.

Need help locating the syntax issue? Help me troubleshoot my security-group blocks.

The groups now define the two security layers. Their separate rule resources specify the allowed traffic for each layer.

  • Add the load balancer rules below aws_security_group.workload by pasting this code:
resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = "0.0.0.0/0"
  description       = "Allow public HTTP traffic to the future load balancer"
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_egress_rule" "alb_all" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = "0.0.0.0/0"
  description       = "Allow the future load balancer to reach targets"
  ip_protocol       = "-1"
}

What can reach the load balancer tier?

  • The ingress rule accepts public TCP traffic on port 80.
  • The egress rule allows the future load balancer to reach its targets.
  • Both rules attach to aws_security_group.alb.
  • Save network.tf.
  • Validate the load balancer rules by running this command:
terraform validate

What does the check confirm?

Terraform checks the rule types plus their security-group references. A successful result confirms that the load balancer group has valid ingress and egress definitions.

Load balancer rules not valid?

Check that the ingress rule includes both port values when ip_protocol is tcp.

Check that the all-protocol egress rule omits port arguments.

Still blocked? Help me fix my load balancer security-group rules.

  • Add the workload rules below aws_vpc_security_group_egress_rule.alb_all by pasting this code:
resource "aws_vpc_security_group_ingress_rule" "workload_from_alb" {
  security_group_id            = aws_security_group.workload.id
  referenced_security_group_id = aws_security_group.alb.id
  description                  = "Allow application traffic only from the load balancer"
  from_port                    = 8080
  ip_protocol                  = "tcp"
  to_port                      = 8080
}

resource "aws_vpc_security_group_egress_rule" "workload_all" {
  security_group_id = aws_security_group.workload.id
  cidr_ipv4         = "0.0.0.0/0"
  description       = "Allow outbound traffic from future workloads"
  ip_protocol       = "-1"
}

How is the workload protected?

  • The workload ingress rule accepts TCP traffic on port 8080.
  • The source is aws_security_group.alb.
  • The workload group can send outbound traffic through its separate egress rule.
  • Save network.tf.
  • Validate the completed security boundary by running the first command below:
  • Preview the security-group additions by running the second command below:
terraform validate
terraform plan

What should the plan contain?

The plan should include two security groups plus four separate rule resources. The workload ingress rule should reference the load balancer group as its source.

Workload rules not validating?

Confirm that security_group_id points to aws_security_group.workload.id.

Confirm that referenced_security_group_id points to aws_security_group.alb.id.

Need help tracing the reference? Help me debug my workload security-group rules.

Use this check to compare your completed network.tf before updating the outputs.

✔️ Awesome, I've got everything!

Your network.tf now contains the full two-zone routing plus security design. Make sure the file is saved.

ⓧ I'd like to double check the full code

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_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = var.project_name
  }
}

resource "aws_subnet" "public_a" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.0.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-a"
    "kubernetes.io/role/elb" = "1"
  }
}

resource "aws_subnet" "public_b" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[1]
  map_public_ip_on_launch = false

  tags = {
    Name                     = "${var.project_name}-public-b"
    "kubernetes.io/role/elb" = "1"
  }
}

resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.10.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name                              = "${var.project_name}-private-a"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

resource "aws_subnet" "private_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = data.aws_availability_zones.available.names[1]

  tags = {
    Name                              = "${var.project_name}-private-b"
    "kubernetes.io/role/internal-elb" = "1"
  }
}

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

  tags = {
    Name = "${var.project_name}-igw"
  }
}

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

  tags = {
    Name = "${var.project_name}-public"
  }
}

resource "aws_route" "public_default" {
  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_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

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

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

  tags = {
    Name = "${var.project_name}-private-a"
  }
}

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

  tags = {
    Name = "${var.project_name}-private-b"
  }
}

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

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

resource "aws_eip" "nat_a" {
  domain = "vpc"

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

  depends_on = [aws_internet_gateway.main]
}

resource "aws_eip" "nat_b" {
  domain = "vpc"

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

  depends_on = [aws_internet_gateway.main]
}

resource "aws_nat_gateway" "a" {
  allocation_id = aws_eip.nat_a.allocation_id
  subnet_id     = aws_subnet.public_a.id

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

  depends_on = [aws_internet_gateway.main]
}

resource "aws_nat_gateway" "b" {
  allocation_id = aws_eip.nat_b.allocation_id
  subnet_id     = aws_subnet.public_b.id

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

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route" "private_a_default" {
  route_table_id         = aws_route_table.private_a.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.a.id
}

resource "aws_route" "private_b_default" {
  route_table_id         = aws_route_table.private_b.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.b.id
}

resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb"
  description = "Public HTTP entry point for a future load balancer"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-alb"
  }
}

resource "aws_security_group" "workload" {
  name        = "${var.project_name}-workload"
  description = "Application traffic accepted only from the load balancer security group"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-workload"
  }
}

resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = "0.0.0.0/0"
  description       = "Allow public HTTP traffic to the future load balancer"
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_egress_rule" "alb_all" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4         = "0.0.0.0/0"
  description       = "Allow the future load balancer to reach targets"
  ip_protocol       = "-1"
}

resource "aws_vpc_security_group_ingress_rule" "workload_from_alb" {
  security_group_id            = aws_security_group.workload.id
  referenced_security_group_id = aws_security_group.alb.id
  description                  = "Allow application traffic only from the load balancer"
  from_port                    = 8080
  ip_protocol                  = "tcp"
  to_port                      = 8080
}

resource "aws_vpc_security_group_egress_rule" "workload_all" {
  security_group_id = aws_security_group.workload.id
  cidr_ipv4         = "0.0.0.0/0"
  description       = "Allow outbound traffic from future workloads"
  ip_protocol       = "-1"
}

The temporary outputs from earlier do not expose the new routing plus security details. Replace them with the completed output set before deployment.

  • Switch to the open outputs.tf tab.
  • Select all existing content in outputs.tf.
  • Replace the selected content with this first output group:
output "availability_zones" {
  description = "Availability Zones used by the network."
  value = [
    data.aws_availability_zones.available.names[0],
    data.aws_availability_zones.available.names[1]
  ]
}

output "nat_gateway_ids" {
  description = "NAT gateway IDs keyed by Availability Zone position."
  value = {
    a = aws_nat_gateway.a.id
    b = aws_nat_gateway.b.id
  }
}

output "private_subnet_ids" {
  description = "Private subnet IDs for future workloads."
  value = [
    aws_subnet.private_a.id,
    aws_subnet.private_b.id
  ]
}

What does this output group expose?

These outputs show the two selected zones plus both NAT gateway IDs. They also preserve the private subnet IDs for future workloads.

  • Save outputs.tf.
  • Validate the first output group by running this command:
terraform validate

What does Terraform check here?

Terraform confirms that the new output references match resources in network.tf.

  • Append the public subnet plus route outputs to outputs.tf by pasting this code:
output "public_subnet_ids" {
  description = "Public subnet IDs for future load balancers."
  value = [
    aws_subnet.public_a.id,
    aws_subnet.public_b.id
  ]
}

output "route_table_ids" {
  description = "Public and private route table IDs."
  value = {
    public    = aws_route_table.public.id
    private_a = aws_route_table.private_a.id
    private_b = aws_route_table.private_b.id
  }
}

output "routing_summary" {
  description = "Default-route targets for each network tier."
  value = {
    public    = "0.0.0.0/0 -> ${aws_internet_gateway.main.id}"
    private_a = "0.0.0.0/0 -> ${aws_nat_gateway.a.id}"
    private_b = "0.0.0.0/0 -> ${aws_nat_gateway.b.id}"
  }
}

Why add a routing summary?

The subnet plus route-table outputs preserve IDs for later infrastructure. The summary turns each default route into a readable tier-to-target mapping.

  • Save outputs.tf.
  • Validate the route output references by running this command:
terraform validate

What does this check protect?

Terraform verifies every gateway plus route-table reference before the configuration reaches AWS.

  • Append the security-group plus VPC outputs to outputs.tf by pasting this code:
output "security_group_ids" {
  description = "Security-group IDs for the future load balancer and workloads."
  value = {
    alb      = aws_security_group.alb.id
    workload = aws_security_group.workload.id
  }
}

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

What completes the output set?

The final outputs expose both security-group IDs. They also preserve the VPC ID used by the route-table inspection command.

  • Save outputs.tf.
  • Validate the completed output set by running this command:
terraform validate

What does the final output check prove?

Terraform confirms that all eight output blocks point to resources defined by the completed configuration.

Output reference not found?

Compare each output reference with the exact resource name in network.tf.

Check that you appended each group after the previous closing brace.

Still seeing an output error? Help me match my Terraform outputs to my resources.

Use this final comparison to confirm that outputs.tf matches the completed project output set.

✔️ Awesome, I've got everything!

Your completed outputs cover zones, gateways, subnets, route tables, routing, security groups, plus the VPC. Make sure outputs.tf is saved.

ⓧ I'd like to double check the full code

output "availability_zones" {
  description = "Availability Zones used by the network."
  value = [
    data.aws_availability_zones.available.names[0],
    data.aws_availability_zones.available.names[1]
  ]
}

output "nat_gateway_ids" {
  description = "NAT gateway IDs keyed by Availability Zone position."
  value = {
    a = aws_nat_gateway.a.id
    b = aws_nat_gateway.b.id
  }
}

output "private_subnet_ids" {
  description = "Private subnet IDs for future workloads."
  value = [
    aws_subnet.private_a.id,
    aws_subnet.private_b.id
  ]
}

output "public_subnet_ids" {
  description = "Public subnet IDs for future load balancers."
  value = [
    aws_subnet.public_a.id,
    aws_subnet.public_b.id
  ]
}

output "route_table_ids" {
  description = "Public and private route table IDs."
  value = {
    public    = aws_route_table.public.id
    private_a = aws_route_table.private_a.id
    private_b = aws_route_table.private_b.id
  }
}

output "routing_summary" {
  description = "Default-route targets for each network tier."
  value = {
    public    = "0.0.0.0/0 -> ${aws_internet_gateway.main.id}"
    private_a = "0.0.0.0/0 -> ${aws_nat_gateway.a.id}"
    private_b = "0.0.0.0/0 -> ${aws_nat_gateway.b.id}"
  }
}

output "security_group_ids" {
  description = "Security-group IDs for the future load balancer and workloads."
  value = {
    alb      = aws_security_group.alb.id
    workload = aws_security_group.workload.id
  }
}

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

Your configuration now describes the full network. The last workflow pass formats it before Terraform checks the final dependency graph.

  • Format the Terraform files by running the first command below:
  • Validate the complete configuration by running the second command below:
  • Preview every planned addition by running the third command below:
terraform fmt
terraform validate
terraform plan

What does this workflow prove?

  • The format command normalizes the Terraform file layout.
  • The validation command checks the complete configuration.
  • The plan shows the egress plus security resources that Terraform intends to create.
  • Review the plan for two Elastic IP addresses.
  • Review the plan for two NAT gateways.
  • Review the plan for two private default routes.
  • Review the plan for two security groups plus four security-group rules.

Know what starts billing

Applying this plan creates billable NAT gateways plus public IPv4 addresses. Each NAT gateway costs $0.045 per hour in US East (Ohio).

Each public IPv4 address costs $0.005 per hour. Partial NAT gateway-hours are billed as full hours.

The NAT gateways take a few minutes to provision. A quiet terminal during that period is normal.

Before you apply, do you expect Terraform to create the new resources without replacing the existing VPC or subnets?

  • Apply the completed configuration by running this command:
  • Follow Terraform's approval prompt to confirm the planned changes.
terraform apply

What happens during the apply?

Terraform allocates both public addresses before provisioning the NAT gateways. It then creates the dependent routes plus security resources.

The apply finishes by displaying the completed root outputs. Those values provide the first view of the deployed routing design.

Apply did not complete?

Check that the active account has permission to create Elastic IP addresses, NAT gateways, routes, security groups, plus security-group rules.

If a NAT gateway fails to provision, review the failure details for its subnet or Elastic IP allocation. Keep the configuration unchanged before retrying the apply.

Need help with the failed resource? Help me diagnose my Terraform apply.

That is the slowest part complete. Both private tiers now have deployed outbound paths plus security boundaries for future application traffic.

Before you inspect the outputs, which target prefix do you expect for the public route and which target prefix do you expect for each private route?

  • Display the readable route mapping by running this command:
terraform output routing_summary

What should the summary show?

The public entry should map 0.0.0.0/0 to an igw- target. Each private entry should map 0.0.0.0/0 to a different nat- target.

  • Display the complete output set by running this command:
terraform output

What should the complete output include?

You should see two NAT gateway IDs plus two security-group IDs. You should also see the subnet, route-table, Availability Zone, plus VPC outputs.

  • Refresh the PowerShell VPC variable by running the first command below:
  • Inspect the deployed route tables by running the second command below:
$VpcId = terraform output -raw vpc_id
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=$VpcId" --region us-east-2 --output table

What proves the routing works?

The public route table should contain a default route through an igw- target. Each private route table should contain a default route through its own nat- target.

Together with the two security-group IDs, this confirms that the private routing gap is closed. The two application tiers now have separate traffic boundaries.

Route table missing a default route?

Confirm that the apply completed after both NAT gateways became available. An interrupted apply can leave dependent routes pending.

Check that $VpcId came from the current workspace output before running the route-table inspection.

Still missing a target? Help me compare Terraform with my AWS route tables.

Secret mission

Add Direct Private Access to Amazon S3

Add an Amazon S3 gateway endpoint to both private route tables. Preserve the AZ-local NAT routes. Prove the new S3 path through Terraform output and the route tables.

Clean Up Your Resources

Each NAT gateway in US East (Ohio) costs $0.045 per hour. Each partial NAT gateway-hour is billed as a full hour.

Each public IPv4 address costs $0.005 per hour. Data processing and transfer can add further charges.

  • Choose Pause or Delete unless your next project starts immediately.

Nice Work!

Nice Work!

You did it. Your production-style two-AZ Amazon VPC can now be recreated from version-pinned Terraform code.

You've learned how to:

  • Turn version-pinned Terraform code into a repeatable two-AZ network with two public subnets plus two private subnets.
  • Expose a missing private egress route through route-table inspection. Close the gap with one NAT gateway in each Availability Zone.
  • Prepare Amazon EKS subnet discovery tags. Use layered security groups to accept workload traffic only from the load balancer tier.
  • Secret Mission: Added an Amazon S3 gateway endpoint to give both private route tables a direct service path.

Ready to quiz yourself?