AWS VPC Security with Terraform

Build a two-tier VPC with layered security controls using Terraform.

Introduction

30 Second Summary

Every time you open an app or browse a website, your data travels through networks designed to let the right traffic in and keep everything else out. In cloud computing, building and securing those networks is your job.

In this project, you will build a two-tier VPC on AWS using Terraform. You will deploy cloud instances into public and private subnets, then progressively lock them down with two distinct security layers.

What You'll Build

You will run a single terraform apply command and watch a complete two-tier network with running instances and layered security controls appear in your AWS account.

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

  • A custom VPC with a public subnet and a private subnet, connected by an internet gateway and dedicated route tables, all deployed entirely through Terraform code.
  • Two EC2 instances that prove your network isolation works. You can see the public one's IP address in the console. The private one has no public IP at all.
  • Two independent security layers: security groups at the instance level and network ACLs at the subnet level, both visible and comparable in the AWS console.
  • Secret Mission: Add VPC Flow Logs to monitor and capture network traffic across your VPC.

Are there any prerequisites?

You need an AWS account with Free Tier access to complete this project. Step 1 walks you through installing Terraform, AWS CLI, and VS Code on a Windows machine.

Before We Start

Before you write any Terraform or touch the AWS console, take a moment to lock in what you are about to build and why it matters.

Install Your Tools

Before you can write any Terraform code, you need three tools on your Windows machine.

In this step, you will install VS Code for editing, the Terraform CLI for deploying infrastructure, and the AWS CLI for connecting to your AWS account.

In this step, get ready to:
  • Install VS Code and the HashiCorp Terraform extension.
  • Install the Terraform CLI via Chocolatey and verify it with terraform -version.
  • Install the AWS CLI and configure your credentials.
Install VS Code and the Terraform extension

VS Code is the code editor where you will write all of your Terraform configuration files.

Once installation finishes, open VS Code.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

You should see the VS Code welcome screen.

Now install the Terraform extension so VS Code can highlight and auto-complete HCL syntax.

  • Press Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows) to open the Extensions panel.
  • Type HashiCorp Terraform in the search bar.
  • Find the extension published by HashiCorp (ID: HashiCorp.terraform).
  • Click Install.

After a few seconds, the Install button changes to a gear icon. The extension is now active.

Don't see the extension?

Make sure you are searching for HashiCorp Terraform (two words). Several community Terraform extensions exist. Pick the one published by HashiCorp.

Still stuck? Help me find and install the HashiCorp Terraform extension in VS Code.

Install Terraform via Chocolatey

Terraform is a command-line tool that reads your configuration files and creates real cloud resources from them. On Windows, the simplest way to install it is through Chocolatey, a package manager that handles downloads and PATH setup automatically.

  • Press the Windows key to open your search bar.
  • Type PowerShell.
  • Right-click Windows PowerShell and select Run as administrator.

Why run as administrator?

Chocolatey installs programs to system-wide directories. Without administrator privileges, the install commands will fail with a permissions error.

  • Install Terraform by running this command:
choco install terraform -y

✔️ I see Terraform downloading and installing

Chocolatey downloaded and installed Terraform. Move on to the verification step below.

ⓧ 'choco' is not recognized

You do not have Chocolatey installed yet. Install it first by running this command in your Administrator PowerShell window:

Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))

Once Chocolatey finishes installing, run the Terraform install command again:

choco install terraform -y

You should see Chocolatey download and install Terraform successfully.

Chocolatey added Terraform to your system PATH, but your current terminal session does not know about it yet. You need a fresh terminal to pick up the change.

  • Open a new PowerShell window (a regular one, not Administrator).
  • Verify Terraform is installed by running this command:
terraform -version

✔️ I see version 1.15 or higher

Terraform is installed and ready. Your project needs version 1.15.0 or higher, and you have it. Move on to the next substep.

ⓧ I see an older version

This project requires Terraform 1.15.0 or higher. You have an older version from a previous install. Upgrade it by running this command in an Administrator PowerShell window:

choco upgrade terraform -y

After the upgrade finishes, open a new PowerShell window and run terraform -version again to confirm you see 1.15.0 or higher.

ⓧ 'terraform' is not recognized

Your terminal cannot find the terraform binary. Make sure you are running the command in a new PowerShell window that was opened after the Chocolatey install completed.

If a new window still does not work, re-run choco install terraform -y in an Administrator PowerShell window and watch for errors.

Help me troubleshoot why terraform is not recognized after installing via Chocolatey on Windows.

Install AWS CLI and configure credentials

The AWS CLI lets Terraform authenticate with your AWS account and create resources on your behalf. You will install it and then store your credentials locally so Terraform can use them.

  • Download the AWS CLI installer from this link.
  • Run the downloaded AWSCLIV2.msi file and accept the default options to complete the installation.
  • Open a new PowerShell window (so the updated PATH takes effect).
  • Verify the AWS CLI is installed by running this command:
aws --version

✔️ I see a version number

The AWS CLI is installed and ready. Move on to configuring your credentials below.

ⓧ 'aws' is not recognized

Your terminal cannot find the aws binary. Make sure you opened a new PowerShell window after running the MSI installer.

If a new window still does not work, re-run the AWSCLIV2.msi installer and make sure it completes without errors.

Help me troubleshoot why the aws command is not recognized on Windows after installing the MSI.

Now connect the CLI to your AWS account. The aws configure command stores your credentials locally so Terraform can read them when it creates resources.

Where do I find my access keys?

Sign in to the AWS console. Click your username in the top-right corner and select Security credentials. Scroll down to the Access keys section and click Create access key.

Copy both the Access Key ID and Secret Access Key somewhere safe. You will only see the Secret Access Key once.

  • Start the credential configuration by running this command:
aws configure

The command prompts you for four values, one at a time. Enter each value and press Enter to move to the next prompt.

  • Enter your AWS Access Key ID when prompted.
  • Enter your AWS Secret Access Key when prompted.
  • Enter us-east-1 for the default region.
  • Enter json for the default output format.

What does aws configure do?

This command saves your credentials and preferences to a file on your machine. Terraform reads this file automatically whenever it needs to authenticate with AWS.

The region us-east-1 tells the CLI (and Terraform) which AWS data center to use by default. You will also set this region explicitly in your Terraform code later.

After you enter the fourth value, the command exits silently with no error. That means your credentials are stored.

Seeing an error after aws configure?

Double-check that your Access Key ID and Secret Access Key are pasted correctly with no extra spaces. You can re-run aws configure at any time to overwrite the stored values.

Help me troubleshoot my AWS CLI credential configuration on Windows.

Your toolbox is ready. Next up, you will create your first Terraform project and build the VPC foundation that every other resource in this project lives inside.

Create the VPC Foundation

Your tools are installed and your AWS credentials are configured. Now it is time to write your first Terraform code.

Every cloud deployment starts with a network. A VPC (Virtual Private Cloud) is your own isolated section of the AWS cloud where you control the IP addresses, subnets, and routing. In this step, you will build the network skeleton that all your future resources will live inside.

In this step, get ready to:
  • Create the Terraform project with the AWS provider and a VPC resource.
  • Add public and private subnets, an internet gateway, and route tables.
  • Deploy the infrastructure and see your VPC and subnet IDs in the terminal output.
Create the Terraform project and provider configuration

Before writing any HCL (HashiCorp Configuration Language), you need a project folder and a file to hold your infrastructure code.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running this command:
mkdir secure-vpc
  • Open the secure-vpc folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the secure-vpc folder, and click Select Folder.

VS Code reloads with secure-vpc as your workspace. You should see the folder name in the Explorer sidebar on the left.

  • Open the integrated terminal again by pressing Ctrl+`.
  • In the Explorer sidebar, click the new file icon (a page with a +).
  • Name the file main.tf and press Enter.

The file opens in the editor. The HashiCorp Terraform extension starts providing syntax highlighting as soon as VS Code recognises the .tf extension.

  • Add the Terraform configuration and your first resource by pasting this code into main.tf:
terraform {
  required_version = ">= 1.15.0"

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

provider "aws" {
  region = "us-east-1"
}

# --- VPC ---
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "secure-vpc"
  }
}

What does this code do?

This file has three sections that set up your Terraform project and create your first AWS resource.

  • terraform {} tells Terraform which version to use (>= 1.15.0) and which provider plugin to download (hashicorp/aws version ~> 6.0).
  • provider "aws" configures the AWS provider to deploy resources in the us-east-1 region.
  • aws_vpc "main" creates your VPC with a CIDR block of 10.0.0.0/16, which gives you over 65,000 IP addresses to assign. enable_dns_support and enable_dns_hostnames let instances inside the VPC resolve DNS names.
  • Save main.tf by pressing Ctrl+S.
  • Initialize the Terraform project and download the AWS provider by running this command in the terminal:
terraform init

You should see Terraform has been successfully initialized! in the terminal output. Terraform downloaded the AWS provider plugin and created a .terraform directory in your project folder.

What did terraform init do?

terraform init reads your provider configuration and downloads the matching plugin (the AWS provider, in this case). It also creates a .terraform.lock.hcl file that locks the exact provider version so future runs use the same one.

Seeing an error after terraform init?

  • Make sure your terminal is inside the secure-vpc folder. Run pwd to check.
  • Check that main.tf is saved. An unsaved file means Terraform cannot read your provider configuration.
  • If you see a network error, check that you have an internet connection. Terraform needs to download the provider plugin from the Terraform Registry.

Why is terraform init failing for me?

Add subnets, internet gateway, and route tables

Your VPC is the container, but it has no internal structure yet. Subnets divide the VPC into smaller network segments. You will create a public subnet that can reach the internet and a private subnet that cannot.

  • In main.tf, add the subnet resources below the VPC resource by pasting this code at the bottom of the file:
# --- Subnets ---
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.2.0/24"

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

What does this code do?

  • The public subnet gets CIDR 10.0.1.0/24 (256 addresses). map_public_ip_on_launch = true means any instance launched here automatically gets a public IP address.
  • The private subnet gets CIDR 10.0.2.0/24. Without map_public_ip_on_launch, instances here have no public IP and are unreachable from the internet.
  • Both subnets reference aws_vpc.main.id to place themselves inside your VPC. Terraform uses this reference to figure out the correct creation order.
  • Save main.tf.

The subnets exist inside the VPC, but the public subnet has no path to the internet yet. An internet gateway is the bridge between your VPC and the public internet. A route table tells traffic where to go.

  • Add the internet gateway and routing configuration below the subnets in main.tf:
# --- Internet Gateway ---
resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "secure-vpc-igw"
  }
}

# --- Route Tables ---
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

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

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

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

What does this code do?

  • aws_internet_gateway "gw" attaches an internet gateway to your VPC, giving it a doorway to the public internet.
  • aws_route_table "public" creates a route table with a rule that sends all traffic destined for 0.0.0.0/0 (any address outside the VPC) through the internet gateway.
  • aws_route_table_association "public" links the route table to the public subnet. Without this association, the subnet would use the VPC's default route table, which has no internet route.

Why does the private subnet have no route table?

The private subnet deliberately uses the VPC's default route table, which only routes traffic within the VPC. It has no route to the internet gateway, so instances in the private subnet are isolated from the public internet by design.

  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf and move on to the next substep.

ⓧ I'd like to double check the full code

Your main.tf should contain these three sections, in order. Scroll through your file and confirm each one is present.

  • Terraform block + AWS provider + VPC resource (the first code you pasted, starting with terraform { and ending with the aws_vpc "main" resource).
  • Public and private subnets (starting with # --- Subnets ---, containing aws_subnet "public" and aws_subnet "private").
  • Internet gateway, route table, and route table association (starting with # --- Internet Gateway ---, ending with aws_route_table_association "public").
  • Preview the infrastructure Terraform will create by running this command:
terraform plan

You should see a summary ending with Plan: 6 to add, 0 to change, 0 to destroy. The six resources are your VPC, two subnets, internet gateway, route table, and route table association.

Seeing errors in the plan?

  • Check for typos in resource references like aws_vpc.main.id. A misspelled resource name causes a "Reference to undeclared resource" error.
  • Make sure you saved main.tf after adding each code block. Terraform reads the file from disk, not the editor buffer.
  • If the error mentions AWS credentials, verify your credentials are still configured by running aws sts get-caller-identity. If that fails, re-run aws configure.

Why is my terraform plan showing errors?

Add outputs and deploy

When Terraform creates resources, AWS assigns unique IDs to each one. Outputs let you print those IDs to the terminal so you can confirm what was created without opening the AWS console.

  • In the Explorer sidebar, click the new file icon and name the file outputs.tf.
  • Add the output definitions by pasting this code into outputs.tf:
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_id" {
  description = "ID of the public subnet"
  value       = aws_subnet.public.id
}

output "private_subnet_id" {
  description = "ID of the private subnet"
  value       = aws_subnet.private.id
}

What does this code do?

Each output block tells Terraform to print a specific value after it finishes creating resources. You will see the VPC ID and both subnet IDs in the terminal after running terraform apply. These IDs are how AWS identifies your resources.

  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved outputs.tf and move on to deploy.

ⓧ I'd like to double check the full code

Your outputs.tf should look exactly like this:

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

output "public_subnet_id" {
  description = "ID of the public subnet"
  value       = aws_subnet.public.id
}

output "private_subnet_id" {
  description = "ID of the private subnet"
  value       = aws_subnet.private.id
}

Everything is ready to deploy. Before you run the next command, how many resources do you think Terraform will create?

  • Deploy your infrastructure to AWS by running this command:
terraform apply

Terraform will ask you to confirm

Terraform displays the full plan, then prompts: Do you want to perform these actions? Type yes and press Enter to create the resources. Only the word yes will be accepted.

Terraform creates all six resources and prints your outputs. You should see three lines at the bottom of the output starting with vpc_id, public_subnet_id, and private_subnet_id, each followed by an AWS resource ID.

What should I see?

The last few lines of output look something like this (your IDs will be different):

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.

Outputs:

private_subnet_id = "subnet-0a1b2c3d4e5f6a7b8"
public_subnet_id = "subnet-9c8d7e6f5a4b3c2d1"
vpc_id = "vpc-0f1e2d3c4b5a69870"

The IDs confirm that AWS created your VPC and both subnets. These are real resources now living in your AWS account.

Apply failed or no outputs showing?

  • If you see an "UnauthorizedOperation" error, your AWS credentials may not have permission to create VPC resources. Confirm you are using an IAM user or role with the AdministratorAccess policy.
  • If you see outputs but no resource IDs, make sure outputs.tf is saved and in the same secure-vpc folder as main.tf. Terraform reads all .tf files in the current directory.
  • If Terraform created fewer than 6 resources, scroll up in the terminal to check for warnings. A missing resource usually means a reference typo in your code.

Why did my terraform apply fail?

Your network skeleton is live in AWS. You have a VPC with two subnets, an internet gateway, and a route table connecting the public subnet to the internet. Next up, you will launch EC2 instances inside these subnets and see what happens when they rely on the default security settings.

Deploy EC2 Instances with Default Security

Your VPC has a network skeleton with subnets, an internet gateway, and route tables. Now you need something actually running inside those subnets.

In this step, you'll launch EC2 instances into both subnets. These instances will use the VPC's default security group, and you'll discover firsthand what that default posture actually allows.

In this step, get ready to:
  • Look up the latest Amazon Linux 2023 AMI automatically.
  • Deploy EC2 instances in the public and private subnets.
  • Observe the default security posture and see what it blocks.
Look up the latest Amazon Linux 2023 AMI and add EC2 instances

Every EC2 instance needs an AMI (Amazon Machine Image) to boot from. Instead of hardcoding an AMI ID that could go stale, you'll use a Terraform data source to find the latest one automatically.

  • In main.tf, scroll down to the aws_route_table_association block at the bottom of the file.
  • Add the AMI lookup data source below it by pasting this code:
# --- AMI Lookup ---
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

What does this code do?

  • data "aws_ami" is a data source that reads information from AWS without creating anything.
  • most_recent = true picks the newest AMI that matches your filters.
  • The two filters narrow the search to Amazon Linux 2023 on x86_64 hardware with HVM virtualization.
  • Save main.tf.

The data source on its own won't produce any visible output. The next chunk uses it to launch your instances.

  • Below the AMI lookup you just added, paste these two EC2 instance resources:
# --- EC2 Instances ---
resource "aws_instance" "public" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id

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

resource "aws_instance" "private" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.private.id

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

What does this code do?

  • Each aws_instance resource launches a virtual server in the subnet you specify.
  • Both use t3.micro, which is covered by the AWS Free Tier.
  • Neither resource specifies a vpc_security_group_ids argument. Terraform assigns them to the VPC's default security group automatically.
  • Save main.tf.
  • Preview the changes by running this command in your VS Code terminal:
terraform plan

You should see a summary line ending with Plan: 3 to add, 0 to change, 0 to destroy. That is 1 data source read plus 2 new EC2 instances.

Seeing errors in the plan?

  • If you see a reference error mentioning data.aws_ami.amazon_linux, make sure the AMI data source block is above the EC2 instance blocks in main.tf.
  • If you see a syntax error, check for missing closing braces or mismatched quotes in the blocks you just added.

Help me debug my terraform plan error.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Your main.tf is over 30 lines, so scroll through and confirm these sections are present, in order:

  • terraform block and provider "aws" block at the top.
  • # --- VPC --- section with aws_vpc.main.
  • # --- Subnets --- section with aws_subnet.public and aws_subnet.private.
  • # --- Internet Gateway --- section with aws_internet_gateway.gw.
  • # --- Route Tables --- section with aws_route_table.public and aws_route_table_association.public.
  • # --- AMI Lookup --- section with data.aws_ami.amazon_linux.
  • # --- EC2 Instances --- section with aws_instance.public and aws_instance.private.

Now you need outputs so Terraform prints the instance IP addresses after deployment.

  • Switch to outputs.tf in VS Code.
  • Below the existing private_subnet_id output, add these three new outputs:
output "public_instance_public_ip" {
  description = "Public IP of the public EC2 instance"
  value       = aws_instance.public.public_ip
}

output "public_instance_private_ip" {
  description = "Private IP of the public EC2 instance"
  value       = aws_instance.public.private_ip
}

output "private_instance_private_ip" {
  description = "Private IP of the private EC2 instance"
  value       = aws_instance.private.private_ip
}

What do these outputs show?

  • public_instance_public_ip is the internet-facing IP address assigned to the public instance. You will use this to test reachability.
  • The two private_ip outputs show the internal addresses inside your VPC. These are how instances talk to each other within the network.
  • Save outputs.tf.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before continuing.

ⓧ I'd like to double check the full code

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

output "public_subnet_id" {
  description = "ID of the public subnet"
  value       = aws_subnet.public.id
}

output "private_subnet_id" {
  description = "ID of the private subnet"
  value       = aws_subnet.private.id
}

output "public_instance_public_ip" {
  description = "Public IP of the public EC2 instance"
  value       = aws_instance.public.public_ip
}

output "public_instance_private_ip" {
  description = "Private IP of the public EC2 instance"
  value       = aws_instance.public.private_ip
}

output "private_instance_private_ip" {
  description = "Private IP of the private EC2 instance"
  value       = aws_instance.private.private_ip
}
Deploy and observe the default security posture

Both files are ready. Time to launch your instances and see what the VPC's default security looks like.

  • Deploy the instances by running this command:
terraform apply
  • Type yes when Terraform prompts you to confirm, then press Enter.

Terraform takes a minute or two to launch the instances. When it finishes, you'll see your outputs printed in the terminal, including a public_instance_public_ip value.

Before you continue, think about this: your public instance now has a public IP address. Do you think you can actually reach that instance from your browser right now? What rules do you think the default security group has?

The answer is no. The VPC's default security group allows inbound traffic only from other resources assigned to the same security group. It does not allow any inbound traffic from the internet. You cannot SSH or HTTP into the public instance yet.

What is the default security group?

Every VPC comes with a default security group. When you launch an instance without specifying a security group, AWS assigns this default one automatically.

The default security group allows all outbound traffic and allows inbound traffic only from other instances in the same group. It blocks everything else from outside the VPC.

  • Return to the AWS Management Console in your browser.
  • Search for EC2 in the top search bar and select EC2.
  • Click Instances (running) in the left sidebar.

You should see two instances: public-instance with a public IP address, and private-instance with no public IP.

  • Click on public-instance to open its details.
  • Select the Security tab.

Under Inbound rules, notice that the only inbound rule allows traffic from the security group itself. There are no rules allowing SSH or HTTP from the internet. This is the gap you'll close in the next step.

Don't see both instances?

  • Make sure you are in the us-east-1 region. Check the region selector in the top-right corner of the console.
  • If you see instances in a terminated state, check your terminal for errors during terraform apply and re-run it.

Help me debug my EC2 instances not appearing in the console.

Your instances are deployed, but they are locked behind a default security group that blocks all inbound internet traffic. Next, you'll create custom security groups that open exactly the ports you need.

Lock Down with Security Groups

Your two EC2 instances are running, but there is a problem. The public instance has a public IP, yet you cannot reach it from the internet.

The default VPC security group blocks all inbound internet traffic, but allows all traffic between instances in the same group. You need custom, per-instance rules that explicitly state which ports are open and to whom.

In this step, get ready to:
  • Create a custom security group for the public instance allowing SSH and HTTP traffic.
  • Create a custom security group for the private instance allowing SSH from the public subnet only.
  • Apply the changes and verify the security posture in the AWS console.
Create a security group for the public instance

A security group acts as a stateful firewall at the instance level. It controls which traffic can reach your instance and which traffic your instance can send out.

What does stateful mean?

A stateful firewall remembers connections. If you allow inbound traffic on port 80, the security group automatically allows the response traffic back out without you writing an explicit outbound rule for it.

This is different from network ACLs, which are stateless and require explicit rules in both directions. You will see that difference first-hand in the next step.

  • In main.tf, add the public security group and its inbound rules below the # --- AMI Lookup --- section, right above the # --- EC2 Instances --- section. Copy the following code:
# --- Security Groups ---
resource "aws_security_group" "public_sg" {
  name        = "public-instance-sg"
  description = "Allow SSH and HTTP from the internet"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "public-instance-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "public_ssh" {
  security_group_id = aws_security_group.public_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "public_http" {
  security_group_id = aws_security_group.public_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
}
  • Add the outbound rule right below the HTTP ingress rule you just pasted:
resource "aws_vpc_security_group_egress_rule" "public_all_out" {
  security_group_id = aws_security_group.public_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

What does this code do?

  • aws_security_group "public_sg" creates an empty security group container attached to your VPC. The rules are defined as separate resources.
  • public_ssh allows inbound TCP traffic on port 22 (SSH) from any IP address (0.0.0.0/0).
  • public_http allows inbound TCP traffic on port 80 (HTTP) from any IP address.
  • public_all_out allows all outbound traffic. The ip_protocol = "-1" means every protocol.

Why standalone rule resources instead of inline blocks?

Terraform's AWS provider supports two ways to define security group rules: inline ingress/egress blocks inside the security group resource, or standalone resources like aws_vpc_security_group_ingress_rule.

The standalone resources are the recommended approach because they prevent rule conflicts in larger configurations. Each rule is its own Terraform resource, so you can add, remove, or modify individual rules without touching the security group definition itself.

Now you need to attach this security group to the public instance.

  • In the # --- EC2 Instances --- section of main.tf, find the aws_instance "public" resource.
  • Find these lines at the top of the resource:
resource "aws_instance" "public" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id
  • Add a vpc_security_group_ids line below subnet_id, so the top of the resource now looks like this:
resource "aws_instance" "public" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.public_sg.id]

Alignment of the equals signs

Notice the spacing of the = signs changed. HCL convention is to align them with the longest attribute name in the block. The HashiCorp Terraform extension in VS Code can auto-format this for you when you save.

  • Save main.tf.
  • Preview the changes by running this command in your VS Code terminal:
terraform plan

The plan output shows the new security group resources Terraform will create and the public instance it will modify. You should see a summary line like Plan: 4 to add, 1 to change, 0 to destroy.

Seeing an error instead of a plan?

  • Check that your security group resources are placed between the AMI Lookup section and the EC2 Instances section. Terraform needs the security group to exist before the instance references it.
  • Make sure the vpc_security_group_ids attribute uses square brackets [ ] around the value. It expects a list, not a single string.
  • Verify that the resource name in aws_security_group.public_sg.id matches the resource name you defined above ("public_sg").

Help me debug my Terraform security group configuration.

Create a security group for the private instance

The private instance sits in a subnet with no internet access. It should only accept SSH connections from the public subnet, so administrators can hop through the public instance to reach it.

  • In main.tf, add the private security group and its rules right below the public_all_out egress rule you added earlier. Copy the following code:
resource "aws_security_group" "private_sg" {
  name        = "private-instance-sg"
  description = "Allow SSH from public subnet only"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "private-instance-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "private_ssh" {
  security_group_id = aws_security_group.private_sg.id
  cidr_ipv4         = "10.0.1.0/24"
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "private_all_out" {
  security_group_id = aws_security_group.private_sg.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"
}

What does this code do?

  • private_ssh only allows SSH from 10.0.1.0/24, which is the public subnet's CIDR range. No traffic from the internet can reach this instance directly.
  • private_all_out allows all outbound traffic so the instance can send responses back.

Now attach this security group to the private instance.

  • In the # --- EC2 Instances --- section, find the aws_instance "private" resource.
  • Find these lines at the top of the resource:
resource "aws_instance" "private" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.private.id
  • Add a vpc_security_group_ids line below subnet_id, so the top of the resource now looks like this:
resource "aws_instance" "private" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.private.id
  vpc_security_group_ids = [aws_security_group.private_sg.id]
  • Save main.tf.
Apply and verify the difference

Both security groups are defined and attached to their instances. Before you apply, think about what you expect Terraform to do. Will it create new instances, or update the existing ones in place?

  • Apply the changes by running this command:
terraform apply
  • Terraform shows a plan summary. Type yes at the Enter a value: prompt and press Enter.

Terraform creates the new security group resources and updates both instances to use them. You should see Apply complete! with resources added and changed.

Now verify the security posture in the AWS console.

  • Switch back to the AWS Management Console in your browser.
  • Navigate to the EC2 service, then click Instances in the left sidebar.
  • Click on your public-instance.
  • Select the Security tab at the bottom of the page.
  • Click on the security group ID link under Security groups to open its inbound rules.

What should I see?

The public instance's security group (public-instance-sg) shows exactly 2 inbound rules: SSH on port 22 and HTTP on port 80, both from 0.0.0.0/0.

If you go back and check the private-instance, its security group (private-instance-sg) shows only 1 inbound rule: SSH on port 22 from 10.0.1.0/24 (the public subnet only).

Not seeing the right inbound rules?

  • Make sure you are looking at the correct security group. Each instance should show its custom security group (public-instance-sg or private-instance-sg), not the default VPC security group.
  • If the instance still shows the default security group, double-check that you added vpc_security_group_ids to both instance resources and ran terraform apply after saving.

Help me check my Terraform security group setup.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf and that terraform apply completed successfully.

ⓧ I'd like to double check the full code

Your main.tf is longer than 30 lines now, so review each section in order. Scroll through your file and confirm these sections are present, top to bottom:

  • Terraform block and AWS provider at the top.
  • VPC (aws_vpc "main") with CIDR 10.0.0.0/16.
  • Subnets (public at 10.0.1.0/24, private at 10.0.2.0/24).
  • Internet gateway and route table with the 0.0.0.0/0 route and public subnet association.
  • AMI lookup data source for Amazon Linux 2023.
  • Security groups section with public_sg (SSH + HTTP ingress, all egress) and private_sg (SSH from 10.0.1.0/24 only, all egress).
  • EC2 instances with vpc_security_group_ids pointing to their respective security groups.

Your outputs.tf is unchanged from the previous step.

Your instances now have explicit, custom security rules instead of the wide-open default. Next up, you will add a second security layer with network ACLs to create true defense-in-depth.

Add Network ACLs for Defense-in-Depth

In the previous step, you attached custom security groups to both instances. Each instance now has explicit rules controlling which ports accept traffic. But security groups are your only line of defense right now.

Security groups are stateful and operate at the instance level. Network ACLs (NACLs) are stateless and operate at the subnet level. By adding NACLs, you create a second, independent layer of defense. If someone misconfigures a security group, the NACL still blocks unauthorized traffic at the subnet boundary.

In this step, get ready to:
  • Create a network ACL for the public subnet with SSH, HTTP, and ephemeral port rules.
  • Create a network ACL for the private subnet restricted to traffic from the public subnet.
  • Compare the two security layers side by side in the AWS console.
Create a NACL for the public subnet

NACLs evaluate rules by number, lowest first, and stop at the first match. They are also stateless, which means return traffic is not automatically allowed. You need explicit rules for every direction.

  • In main.tf, add the following code at the bottom of the file to create the public subnet's NACL with SSH and HTTP inbound rules:
# --- Network ACLs ---
resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.public.id]

  # Inbound SSH
  ingress {
    rule_no    = 100
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 22
    to_port    = 22
  }

  # Inbound HTTP
  ingress {
    rule_no    = 110
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

What does this code do?

This creates an aws_network_acl resource associated with your public subnet via subnet_ids.

  • rule_no sets the evaluation order. Rule 100 is checked first, then 110. AWS stops at the first match.
  • The SSH rule (port 22) and HTTP rule (port 80) mirror the security group rules you already created. The NACL enforces them again at the subnet boundary.

The resource is not complete yet. NACLs are stateless, so you need to explicitly allow return traffic.

  • Still inside the aws_network_acl.public resource, add the remaining rules directly below the HTTP ingress block:
  # Inbound ephemeral ports (return traffic)
  ingress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  # Outbound all
  egress {
    rule_no    = 100
    protocol   = "-1"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 0
    to_port    = 0
  }

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

Why do NACLs need ephemeral port rules?

Security groups are stateful. When they allow an inbound connection, the return traffic is automatically allowed. NACLs are stateless. Every packet is evaluated independently, even if it is a response to an allowed request.

When your instance responds to an SSH or HTTP request, it sends the reply from a random high port (1024-65535). Without the ephemeral ports rule, those replies would be silently dropped at the subnet boundary.

  • Save main.tf.
  • Preview the changes by running this command in your VS Code terminal:
terraform plan
  • You should see Terraform planning to add 1 new resource. That is the public NACL you just defined.

Seeing a syntax error?

  • Make sure the closing } for the resource block comes after the tags block. Each ingress and egress block also needs its own closing brace.
  • Check that protocol values are in quotes. The egress rule uses "-1" (a string meaning all protocols), not a bare number.
  • help me fix a syntax error in my Terraform NACL resource
Create a NACL for the private subnet

The private subnet's NACL is more restrictive. It only allows traffic from the public subnet's CIDR block, not the entire internet.

  • In main.tf, add the following code directly below the closing brace of the public NACL resource:
resource "aws_network_acl" "private" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.private.id]

  # Inbound SSH from public subnet
  ingress {
    rule_no    = 100
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "10.0.1.0/24"
    from_port  = 22
    to_port    = 22
  }

  # Inbound ephemeral ports from public subnet (return traffic)
  ingress {
    rule_no    = 110
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "10.0.1.0/24"
    from_port  = 1024
    to_port    = 65535
  }

What does this code do?

  • Both ingress rules use 10.0.1.0/24 as the source. Only traffic from the public subnet can enter. The entire internet is blocked.
  • Rule 100 allows SSH so the public instance can connect. Rule 110 allows ephemeral ports so responses to outbound connections can come back.
  • Still inside the aws_network_acl.private resource, add the outbound rule and tags directly below the ephemeral ports ingress block:
  # Outbound to public subnet
  egress {
    rule_no    = 100
    protocol   = "-1"
    action     = "allow"
    cidr_block = "10.0.1.0/24"
    from_port  = 0
    to_port    = 0
  }

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

Why is outbound traffic restricted too?

The private instance's egress rule only allows traffic to 10.0.1.0/24 (the public subnet). It cannot send traffic to the open internet. A private instance that cannot reach the internet is much harder for an attacker to use as a pivot point.

  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before continuing.

ⓧ I'd like to double check the full code

Your main.tf is now too long to show in one block. Scroll through your file and confirm these sections appear in order:

  • The terraform block and provider "aws" block at the top.
  • The aws_vpc.main resource, followed by aws_subnet.public and aws_subnet.private.
  • The aws_internet_gateway.gw, aws_route_table.public, and aws_route_table_association.public resources.
  • The data "aws_ami" block and all security group resources (aws_security_group and their ingress/egress rules).
  • Both aws_instance resources (public and private).
  • The new aws_network_acl.public resource with 3 ingress rules (SSH, HTTP, ephemeral) and 1 egress rule.
  • The new aws_network_acl.private resource with 2 ingress rules (SSH, ephemeral) and 1 egress rule.
Apply and compare the two security layers

Before you run this, how many new resources do you expect Terraform to create? Think about what you added since the last apply.

  • Deploy the NACLs by running this command:
terraform apply
  • Type yes when Terraform asks you to confirm.

Terraform reports 2 added. That matches the two NACL resources you defined. Your existing instances, security groups, and VPC are untouched.

What about the deny-all rule?

You did not write any deny rules. AWS automatically appends a deny-all rule (rule number 32767) to the end of every NACL. Any traffic that does not match one of your explicit allow rules is denied by default.

Now compare the two security layers in the AWS console to see the defense-in-depth model in action.

  • Switch to the AWS Management Console in your browser.
  • Search for VPC in the top search bar and open the VPC service.
  • Click Network ACLs in the left sidebar.
  • Click on public-nacl.
  • Open the Inbound rules tab. You should see three explicit allow rules (SSH on port 22, HTTP on port 80, ephemeral ports 1024-65535) followed by the auto-generated deny-all rule at rule number 32767.
  • Click on private-nacl and check its Inbound rules. You should see only SSH (port 22) and ephemeral ports (1024-65535), both restricted to 10.0.1.0/24.

Now compare these NACLs to the security groups you created in the previous step.

  • Click Security groups in the left sidebar of the VPC console.
  • Click on public-instance-sg and look at the Inbound rules tab. It shows 2 inbound rules (SSH and HTTP). There is no deny-all rule and no ephemeral port rule. Security groups handle return traffic automatically because they are stateful.

How do security groups and NACLs work together?

This is the defense-in-depth model. Traffic flowing to your public instance must pass through both layers.

  • Security groups are stateful, instance-level, and allow-only. They track connections, so return traffic is automatic.
  • NACLs are stateless, subnet-level, and support both allow and deny rules. They evaluate every packet independently using numbered rules.
  • If someone accidentally opens all ports in a security group, the NACL still blocks everything except SSH and HTTP at the subnet boundary.

Don't see your NACLs in the console?

  • Make sure you are in the us-east-1 region. Check the region dropdown in the top right of the console.
  • The VPC console shows NACLs for all VPCs by default. Filter by your secure-vpc VPC to find the ones you created.
  • If terraform apply succeeded but the console looks empty, try refreshing the browser page.
  • help me find my NACLs in the AWS console

Your VPC now has two independent security layers. Security groups guard each instance, and NACLs guard each subnet. Next up, you will take on a Secret Mission to add visibility into the traffic flowing through your VPC.

Secret mission

Add VPC Flow Logs

You have locked down traffic at the instance and subnet levels. But can you actually see what traffic is flowing? In this mission, you will add VPC Flow Logs to capture metadata about every connection attempt across your VPC. You will wire up IAM roles, CloudWatch log groups, and Terraform resources you have not used before to gain real visibility into your network.

Clean Up Your Resources

Clean Up Your Resources

This project created AWS resources that consume Free Tier hours while running. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Cost warning

Your two t3.micro EC2 instances and their public IPv4 addresses consume Free Tier hours while running. Left running 24/7, they will exhaust your monthly Free Tier allowance in about 2 weeks.

If you are done with the project, the Delete option below is recommended.

Resources you used:

  • 2 EC2 instances (t3.micro) running in your VPC.
  • 1 custom VPC (secure-vpc) with a public subnet and a private subnet.
  • 1 internet gateway, route tables, security groups, and network ACLs.
  • CloudWatch log group, IAM role, and VPC flow log (if you completed the Secret Mission).
  • Local secure-vpc project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project soon.

  • Your EC2 instances will continue consuming Free Tier hours. Monitor your usage on the AWS Billing console under Free Tier to avoid surprise charges.
  • The VPC, subnets, route tables, internet gateway, security groups, and NACLs have no hourly cost.
  • Terraform, AWS CLI, and VS Code remain installed on your machine. These are general-purpose tools you can reuse for future projects.

Pause - I'll come back to this later

Stop the EC2 instances to avoid compute charges. Your VPC, subnets, and security rules cost nothing while idle.

  • Return to the AWS EC2 console and click Instances in the left sidebar.
  • Select both public-instance and private-instance.
  • Click Instance state, then Stop instance.
  • Public IPv4 addresses cost $0.005/hr when not associated with a running instance. Stopping instances does not release the address, so a small charge may still apply.
  • To resume, select both instances, click Instance state, then Start instance.

Delete - I don't want to use this again

Remove all AWS resources with a single Terraform command, then delete the local project folder. This is the recommended option if you are done.

  • In your VS Code terminal, make sure you are inside the secure-vpc folder.
  • Run the following command to destroy all AWS resources:
terraform destroy
  • Terraform will list every resource it plans to delete. Type yes when prompted and press Enter.
  • Wait for Terraform to finish. You should see a message confirming all resources have been destroyed.
  • Return to the AWS VPC console and confirm that secure-vpc no longer appears in the list of VPCs.
  • Return to the AWS EC2 console and confirm that public-instance and private-instance are in a terminated state.

Now delete the local project folder. Navigate to your Desktop and remove it by running these commands:

cd ~\Desktop
Remove-Item -Recurse -Force secure-vpc
  • Confirm the folder is gone by running:
ls
  • You should no longer see secure-vpc in the output.

Nice Work!

Nice Work!

Nice work! You just built a production-style, two-tier VPC on AWS with layered security controls, deployed entirely through Terraform code.

You've learned how to:

  • Built a custom VPC with public and private subnets, an internet gateway, and dedicated route tables using Terraform.
  • Applied layered security controls: security groups as the stateful, instance-level firewall and network ACLs as the stateless, subnet-level defense-in-depth boundary.
  • Mastered the Terraform workflow (init, plan, apply, destroy) to deploy, modify, and tear down cloud infrastructure from code.
  • Secret Mission: Added VPC Flow Logs with IAM roles and CloudWatch to gain visibility into network traffic patterns.

Ready to quiz yourself?