Deploy a Production VPC with Terraform

Build a full AWS VPC with subnets, NAT gateway, and VPC endpoints in Terraform.

Introduction

30 Second Summary

Every time you open a website, your request passes through layers of network rules that decide what gets in and what stays out. Companies build these networks to protect their servers while still letting the right traffic flow.

In this project, you will use Terraform to deploy a production-grade VPC on AWS with public and private subnets, a NAT gateway, security groups, NACLs, and VPC endpoints.

What You'll Build

A complete Terraform configuration that deploys a fully operational AWS network with layered security and private connectivity to AWS services.

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

  • A VPC with public and private subnets, an internet gateway, and a NAT gateway providing outbound-only internet access for private resources.
  • Layered network security using security groups (stateful, instance-level) and NACLs (stateless, subnet-level) with explicit ingress and egress rules.
  • A gateway endpoint for S3 and an interface endpoint for SSM, connecting private resources directly to AWS services without internet exposure.
  • Secret Mission: Launch a private EC2 instance and connect to it through SSM Session Manager with no public IP or SSH key.

Are there any prerequisites?

No prior experience with Terraform or AWS networking is required. Step 1 walks you through installing all tools and configuring your AWS account from scratch.

Install Terraform and Configure AWS

Before you can define any infrastructure as code, you need the tools that translate your code into real AWS resources.

In this step, you will install Terraform (the tool that reads your configuration files and creates AWS resources) and the AWS CLI (the tool that authenticates you with your AWS account). You will also create the project folder and files where all your Terraform code will live.

In this step, get ready to:
  • Install Terraform and verify it is version 1.0 or higher.
  • Install and configure the AWS CLI with your credentials.
  • Create the project directory and initial Terraform files.
Install Terraform

Terraform is the Infrastructure as Code tool you will use throughout this project to define and deploy your VPC network. It reads .tf configuration files and translates them into AWS API calls.

  • Open your terminal.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check if Terraform is already installed by running this command:
terraform -version

✔️ I see version 1.0 or higher

Terraform is ready to go. Skip ahead to the next substep (Install and configure the AWS CLI).

ⓧ I see an older version

You need to upgrade Terraform to version 1.0 or higher. Uninstall your current version and follow the install steps below.

macOS

  • Upgrade Terraform by running:
brew install terraform

Windows

  • Upgrade Terraform by running:
choco install terraform
  • After the upgrade completes, verify by running:
terraform -version

You should now see a version number of 1.0 or higher.

ⓧ Command not found

Terraform is not installed yet. Follow the instructions for your operating system below.

macOS

  • Install Terraform using Homebrew by running:
brew install terraform
Don't have Homebrew?

If brew is not recognized, install Homebrew first by visiting https://brew.sh and following the one-line install command on that page.

Windows

  • Install Terraform using Chocolatey by running:
choco install terraform
Don't have Chocolatey?

If choco is not recognized, install Chocolatey first by visiting https://chocolatey.org/install and following the instructions for PowerShell.

  • After the install completes, close and reopen your terminal.
  • Verify the installation by running:
terraform -version

You should see a version number of 1.0 or higher.

Still not seeing Terraform?

Make sure you closed and reopened your terminal after installing. Your shell needs to reload its PATH to find the new binary.

On Windows, try running PowerShell as Administrator for the Chocolatey install.

help me troubleshoot why terraform -version is not working after installation

Install and configure the AWS CLI

The AWS CLI lets you authenticate with your AWS account from the terminal. Terraform uses these stored credentials to create resources in your account.

  • Check if the AWS CLI is already installed by running:
aws --version

✔️ I see a version number

The AWS CLI is installed. Skip ahead to the configuration step below.

ⓧ Command not found

Install the AWS CLI for your operating system.

macOS

  • Install the AWS CLI using Homebrew by running:
brew install awscli

Windows

  • Download and install the AWS CLI by running this in PowerShell:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi

After the installer finishes, close and reopen your terminal.

  • Verify the installation by running:
aws --version

You should see a version string starting with aws-cli/2.

Don't have an AWS account?

If you do not have an AWS account yet, create one at https://aws.amazon.com.

After creating your account, create an IAM user with AdministratorAccess and generate access keys. You will need the Access Key ID and Secret Access Key for the next step.

Now configure the AWS CLI with your credentials so Terraform can authenticate.

  • Run the configuration command:
aws configure

What does aws configure ask for?

The aws configure command prompts you for four values, one at a time:

  • AWS Access Key ID - paste the access key from your IAM user.
  • AWS Secret Access Key - paste the secret key from your IAM user.
  • Default region name - enter us-east-1 (or your preferred region).
  • Default output format - enter json.
  • Enter each value when prompted and press Enter after each one.
  • Verify your credentials are saved correctly by running:
aws sts get-caller-identity

You should see a JSON response containing your AWS account ID, user ARN, and user ID. This confirms your credentials are working.

Seeing an access denied or credentials error?

Double-check that you pasted your Access Key ID and Secret Access Key correctly. Run aws configure again to re-enter them.

Make sure your IAM user has the AdministratorAccess policy attached. Without it, Terraform cannot create VPC resources.

help me fix AWS CLI credentials error after running aws configure

Create the project directory

With your tools installed and authenticated, you need a folder to hold your Terraform configuration files. Terraform reads all .tf files in the current directory as a single configuration.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir vpc-terraform
cd vpc-terraform
  • Create the initial Terraform files by running:
touch main.tf variables.tf outputs.tf

What are these three files?

main.tf holds your resource definitions (the VPC, subnets, gateways, and endpoints you will build). variables.tf defines configurable inputs like your region and CIDR ranges. outputs.tf declares values Terraform should print after deploying (like resource IDs).

  • Confirm the files exist by running:
ls

You should see main.tf, variables.tf, and outputs.tf in the output.

  • Open the vpc-terraform folder in VS Code (or your preferred editor).
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the vpc-terraform folder, and click Open.

You should see main.tf, variables.tf, and outputs.tf listed in the Explorer sidebar on the left.

Don't see your files in VS Code?

Make sure you opened the vpc-terraform folder itself, not your entire Desktop. The Explorer sidebar should show only the three .tf files.

If the Explorer sidebar is hidden, press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle it open.

help me open my vpc-terraform folder correctly in VS Code

Your environment is ready. Next up, you will write the VPC and CIDR plan that defines the shape of your entire network.

Define the VPC and CIDR Plan

Your tools are installed and your project directory is ready. Now it's time to plan the network itself.

Every VPC starts with a decision about IP address space. You need to choose how many IP addresses your network can hold and how to divide them across subnets. In this step, you'll plan the CIDR ranges for your VPC and subnets, then write the Terraform code that brings them to life.

In this step, get ready to:
  • Plan your network's IP address space using CIDR notation.
  • Write the Terraform provider configuration, VPC, and internet gateway.
  • Define variables for reusable configuration values.
Understand CIDR notation

CIDR notation tells you how large a network is. The number after the slash indicates how many bits are locked for the network portion of the address.

What is CIDR notation?

A /16 gives you 65,536 IP addresses. A /24 gives you 256 IP addresses. The smaller the number after the slash, the bigger the network.

Your VPC will use 10.0.0.0/16 as its address space. You'll carve two /24 subnets from within it: 10.0.1.0/24 (public) and 10.0.2.0/24 (private).

The key rule: subnet CIDRs must be subsets of the VPC CIDR and must not overlap each other. Both 10.0.1.0/24 and 10.0.2.0/24 fit inside 10.0.0.0/16 and do not overlap.

Write the Terraform provider and VPC

Terraform needs to know which cloud provider to use and where to deploy resources. You'll configure this in main.tf along with the VPC and internet gateway.

  • Open main.tf in your editor and add the Terraform configuration block and AWS provider:
terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

What does this code do?

  • terraform block pins the minimum Terraform version to 1.0 and specifies the AWS provider from HashiCorp's registry at version ~> 6.0.
  • provider "aws" tells Terraform to use your AWS credentials and deploy to the region specified by var.aws_region (a variable you'll define shortly).
  • Save main.tf.

You won't see output yet. The next chunk adds the resources that use this provider configuration.

  • Below the provider block in main.tf, add the VPC and internet gateway resources:
# --- VPC ---
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = { Name = "my-lab-vpc" }
}

# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "my-lab-igw" }
}

What does this code do?

  • aws_vpc creates a VPC with the CIDR block from your variable. DNS support and hostnames are enabled so instances get resolvable DNS names.
  • aws_internet_gateway attaches a gateway to the VPC. This is what allows resources in public subnets to reach the internet.
  • Save main.tf.

You won't see any terminal output yet. You'll validate everything with terraform init in the next step.

Seeing red underlines in your editor?

If your editor shows errors on var.vpc_cidr or var.aws_region, that's expected. You haven't defined those variables yet. The next substep creates them.

help me fix editor warnings in my Terraform files

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf before moving on.

ⓧ I'd like to double check the full code

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

provider "aws" {
  region = var.aws_region
}

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

  tags = { Name = "my-lab-vpc" }
}

# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "my-lab-igw" }
}
Define variables

Your main.tf references variables like var.aws_region and var.vpc_cidr. Now you need to define them so Terraform knows their types and default values.

  • Open variables.tf and add the following variable definitions:
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC (/16 = 65,536 IPs)"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidr" {
  description = "CIDR block for the public subnet (/24 = 256 IPs)"
  type        = string
  default     = "10.0.1.0/24"
}

variable "private_subnet_cidr" {
  description = "CIDR block for the private subnet (/24 = 256 IPs)"
  type        = string
  default     = "10.0.2.0/24"
}

variable "my_ip" {
  description = "Your public IP for SSH access (CIDR notation, e.g. 203.0.113.1/32)"
  type        = string
}

What does this code do?

  • Each variable block declares a named input with a type and optional default value.
  • vpc_cidr defaults to 10.0.0.0/16 (65,536 IPs). The two subnet variables carve /24 slices (256 IPs each) from within that range.
  • my_ip has no default because it's unique to you. Terraform will prompt you for it at apply time.
  • Save variables.tf.

In the next step, you'll run terraform init to download the AWS provider and terraform plan to verify all the references resolve correctly.

Still seeing errors on var references?

Make sure your variable names match exactly. main.tf uses var.vpc_cidr and variables.tf defines vpc_cidr. A single typo breaks the link.

help me debug variable reference errors in Terraform

✔️ Awesome, I've got everything!

Great. Double check you've saved variables.tf before moving on.

ⓧ I'd like to double check the full code

variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC (/16 = 65,536 IPs)"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidr" {
  description = "CIDR block for the public subnet (/24 = 256 IPs)"
  type        = string
  default     = "10.0.1.0/24"
}

variable "private_subnet_cidr" {
  description = "CIDR block for the private subnet (/24 = 256 IPs)"
  type        = string
  default     = "10.0.2.0/24"
}

variable "my_ip" {
  description = "Your public IP for SSH access (CIDR notation, e.g. 203.0.113.1/32)"
  type        = string
}

Your VPC blueprint is defined. Next up, you'll create the subnets and route tables that control how traffic flows through this network.

Create Subnets and Route Tables

Your VPC is live in code, but it is one big empty address space. To actually place resources inside it, you need to carve it into smaller segments called subnets.

In this step, you will create a public subnet (internet-facing) and a private subnet (internal only). You will also set up a route table that directs internet-bound traffic from the public subnet through the internet gateway.

In this step, get ready to:
  • Create public and private subnets with different access patterns.
  • Create a public route table that sends internet traffic through the internet gateway.
  • Initialize Terraform and preview your infrastructure plan.
Create the public and private subnets

A subnet is a range of IP addresses inside your VPC. Public subnets have a direct route to an internet gateway. Private subnets do not.

  • In main.tf, find the internet gateway resource at the bottom of the file.
  • Add the following code below it to define both subnets:
# --- Subnets ---
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = false

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

What does this code do?

Each subnet references the VPC it belongs to and takes a CIDR block from the variables you defined earlier. Both subnets are placed in the same availability zone (the a zone of your chosen region).

The key difference is map_public_ip_on_launch. Setting it to true means any instance launched in the public subnet automatically gets a public IP address. The private subnet sets it to false, keeping instances hidden from the internet.

  • Save main.tf.

Seeing a syntax error in your editor?

Check that the interpolation syntax "${var.aws_region}a" includes the opening ${ and closing } around the variable reference. The a suffix sits outside the braces.

Also confirm each resource block has matching opening and closing braces. help me fix a syntax error in my Terraform subnet resource

Create the public route table

A route table contains rules that determine where network traffic is directed. Without one, your public subnet cannot actually reach the internet, even though an internet gateway exists.

  • In main.tf, add the following code below your private subnet resource:
# --- 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.main.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?

The route table defines a rule: any traffic headed to 0.0.0.0/0 (which means "anywhere on the internet") should go through the internet gateway.

The aws_route_table_association resource links this route table to the public subnet. Without this association, the subnet would use the VPC's default route table, which has no internet route.

The private subnet does not get a route table yet. You will add one in the next step that routes through a NAT gateway instead.

  • Save main.tf.

Not sure where to place the code?

Make sure your route table code sits after the two subnet resources. Terraform does not care about resource ordering within a file (it resolves dependencies automatically), but keeping related resources grouped makes the file easier to read.

help me organize my main.tf file

Run terraform init and plan

Before you can use any Terraform provider (like AWS), you need to download it. The terraform init command handles this. After that, terraform plan shows you exactly what Terraform will create without actually building anything.

  • Initialize your Terraform project by running this command in your terminal (from inside the vpc-terraform directory):
terraform init
  • You should see a message saying Terraform has been successfully initialized and that it installed the hashicorp/aws provider.
  • Preview your infrastructure plan by running:
terraform plan

What happens when Terraform prompts for my_ip?

Terraform will ask you to enter a value for my_ip because that variable has no default. Enter your public IP in CIDR notation (for example, 203.0.113.1/32). You can find your public IP by searching "what is my IP" in your browser, then adding /32 to the end.

The /32 means "this single IP address only." You are not applying anything yet, so this is safe to experiment with.

  • Review the plan output. You should see Terraform listing resources it will create, including your VPC, internet gateway, two subnets, a route table, and a route table association.
  • Do not run terraform apply yet. You will apply everything together in a later step once the full network is defined.

Seeing an error during terraform init?

Make sure you are running the command from inside the vpc-terraform directory where your .tf files live. Terraform looks for configuration files in the current directory.

If you see a provider version error, confirm your main.tf has version = "~> 6.0" in the required_providers block.

help me fix a terraform init error

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete main.tf file as it should look at this point:

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

provider "aws" {
  region = var.aws_region
}

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

  tags = { Name = "my-lab-vpc" }
}

# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "my-lab-igw" }
}

# --- Subnets ---
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = false

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

# --- 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.main.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
}

Your subnets are defined and your public route table points to the internet gateway. Next up, you will add a NAT gateway that gives the private subnet outbound internet access without exposing it to incoming traffic.

Add the NAT Gateway

Your public subnet can reach the internet directly through the internet gateway. But your private subnet has no route to the outside world.

Private instances still need outbound internet access for things like downloading updates and pulling packages. A NAT gateway solves this by letting private instances send traffic out without allowing anything from the internet to reach them directly.

In this step, get ready to:
  • Create an Elastic IP and NAT gateway in the public subnet.
  • Create a private route table that sends internet traffic through the NAT gateway.
  • Understand how NAT gives private instances outbound-only access.
Create the Elastic IP and NAT gateway

A NAT gateway needs a fixed public IP address so that outbound traffic from private instances always appears to come from the same address. An Elastic IP provides that static address.

  • In main.tf, find the # --- Route Tables --- comment.
  • Add the following code above that comment, directly after the private subnet resource:
# --- Elastic IP and NAT Gateway ---
resource "aws_eip" "nat" {
  domain = "vpc"
  tags   = { Name = "nat-eip" }
}

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

  tags = { Name = "my-lab-nat" }

  depends_on = [aws_internet_gateway.main]
}

What does this code do?

  • aws_eip "nat" allocates a static public IP address in the VPC domain. This IP belongs to your account until you release it.
  • aws_nat_gateway "main" creates the NAT gateway in the public subnet, attached to that Elastic IP. It lives in the public subnet so it can reach the internet, but it serves traffic from the private subnet.
  • depends_on ensures the internet gateway is fully created before the NAT gateway. Without internet gateway connectivity, the NAT gateway cannot function.
  • Save main.tf.

You won't see a change yet. The next chunk adds the private route table that actually sends traffic through this NAT gateway.

Create the private route table

The NAT gateway exists, but no traffic flows to it yet. You need a route table that tells the private subnet to send internet-bound traffic through the NAT.

  • In main.tf, scroll to the bottom of the file (after the aws_route_table_association "public" resource).
  • Add the following code at the end:
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

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

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

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

What does this code do?

  • aws_route_table "private" creates a route table with a default route (0.0.0.0/0) pointing to the NAT gateway. Any traffic headed for the internet leaves through the NAT instead of going directly.
  • aws_route_table_association "private" links this route table to the private subnet. Without this association, the private subnet would use the VPC's default route table (which has no internet route at all).
  • Save main.tf.
  • Verify the new resources by running:
terraform plan

You should see a plan summary showing resources to add, including the EIP, NAT gateway, private route table, and route table association.

Seeing errors in terraform plan?

  • Check for missing closing braces. Every { needs a matching }.
  • Make sure aws_nat_gateway.main.id in the route table matches the NAT gateway resource name exactly.
  • Confirm the EIP and NAT gateway code is placed before the # --- Route Tables --- section, not inside another resource block.

help me debug my terraform plan error for the NAT gateway

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf before moving on.

ⓧ I'd like to double check the full code

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

provider "aws" {
  region = var.aws_region
}

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

  tags = { Name = "my-lab-vpc" }
}

# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "my-lab-igw" }
}

# --- Subnets ---
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = false

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

# --- Elastic IP and NAT Gateway ---
resource "aws_eip" "nat" {
  domain = "vpc"
  tags   = { Name = "nat-eip" }
}

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

  tags = { Name = "my-lab-nat" }

  depends_on = [aws_internet_gateway.main]
}

# --- 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.main.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
}

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

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

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

resource "aws_route_table_association" "private" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}
Understand the NAT pattern

How does the NAT gateway work?

When a private instance sends a request to the internet, the NAT gateway translates the instance's private IP to its own public Elastic IP. The destination server sees the Elastic IP as the source and sends the response back to it.

The NAT gateway then forwards the response back to the private instance. But nothing from the internet can initiate a new connection to the private instance. This is how production servers download updates and pull packages without being internet-facing.

Why does the NAT gateway live in the public subnet?

The NAT gateway needs direct internet access through the internet gateway to forward traffic on behalf of private instances. Only resources in the public subnet have a route to the internet gateway. The private subnet routes to the NAT, and the NAT routes to the internet gateway.

Your private subnet now has a one-way door to the internet. Next up, you'll add security groups and NACLs to control exactly what traffic is allowed in and out.

Add Security Groups and NACLs

Your VPC now has a NAT gateway that gives private instances outbound internet access without exposing them to inbound connections.

Routing alone does not protect your resources from unwanted traffic. In this step, you will add two layers of security: security groups (stateful, instance-level firewalls) and NACLs (stateless, subnet-level firewalls).

In this step, get ready to:
  • Create a public and private security group with specific ingress and egress rules.
  • Create a custom NACL for the public subnet with numbered allow rules.
  • Understand the difference between stateful and stateless firewalls in your code.
Create security groups

Security groups act as virtual firewalls attached to individual resources. They are stateful, meaning if you allow inbound traffic, the response is automatically allowed out without needing an explicit egress rule.

  • In main.tf, add the following code below your last aws_route_table_association resource to define the public security group:
# --- Security Groups ---

# Security groups are STATEFUL: return traffic is automatically allowed.
resource "aws_security_group" "public" {
  name        = "public-sg"
  description = "Allow SSH from my IP and HTTP from anywhere"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

What does this code do?

  • The first ingress rule allows SSH (port 22) only from your personal IP address, stored in var.my_ip. Nobody else can SSH into public instances.
  • The second ingress rule allows HTTP (port 80) from anywhere, so web traffic can reach your public instances.
  • The egress block allows all outbound traffic. Because security groups are stateful, you do not need a separate rule for response traffic.

You won't see a change yet. The next chunk adds the private security group.

  • Below the public security group you just added, add the private security group:
resource "aws_security_group" "private" {
  name        = "private-sg"
  description = "Allow SSH from public subnet and HTTPS for endpoints"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from public subnet"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.public_subnet_cidr]
  }

  ingress {
    description = "HTTPS for VPC endpoints and AWS services"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

What does this code do?

  • SSH ingress is restricted to var.public_subnet_cidr (10.0.1.0/24). Only instances in the public subnet can SSH into private instances.
  • HTTPS ingress (port 443) is restricted to var.vpc_cidr (10.0.0.0/16). This allows VPC endpoints to communicate with private instances over HTTPS without opening traffic to the entire internet.
  • Egress allows all outbound traffic so private instances can reach the NAT gateway and AWS services.
  • Save main.tf.

Seeing a syntax error?

  • Make sure each ingress and egress block has matching opening and closing braces.
  • Check that cidr_blocks uses square brackets for the list, e.g. [var.my_ip] not just var.my_ip.
  • Verify that protocol is set to "tcp" (with quotes) for specific ports and "-1" for the all-traffic egress rule.

Why is my security group code producing errors?

Create a custom NACL for the public subnet

NACLs operate at the subnet level and are stateless. Every packet is evaluated against the rules independently, so you must explicitly allow both inbound traffic and its outbound response.

  • In main.tf, add the following NACL resource below your private security group:
# --- Network ACL (STATELESS: must explicitly allow return traffic) ---
resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.public.id]

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

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

  ingress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  # Outbound rules - MUST explicitly allow responses (stateless!)
  egress {
    rule_no    = 100
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  egress {
    rule_no    = 110
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  egress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = var.private_subnet_cidr
    from_port  = 22
    to_port    = 22
  }

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

What does this code do?

The NACL has numbered rules evaluated from lowest to highest. The first matching rule wins.

  • Ingress rule 100 allows SSH (port 22) from anywhere.
  • Ingress rule 110 allows HTTP (port 80) from anywhere.
  • Ingress rule 120 allows ephemeral ports (1024-65535) from anywhere. These are the ports that response packets arrive on when your instance makes outbound connections.
  • Egress rule 100 allows ephemeral ports outbound. Without this, SSH and HTTP response packets cannot leave the subnet.
  • Egress rule 110 allows HTTP (port 80) outbound for making web requests.
  • Egress rule 120 allows SSH (port 22) outbound only to the private subnet (10.0.2.0/24), so public instances can SSH into private ones.
  • Save main.tf.
  • Confirm there are no syntax errors by running:
terraform plan

Terraform will prompt you for the my_ip variable since it has no default. Enter your public IP in CIDR notation (e.g. 203.0.113.1/32).

Find your public IP

Search "what is my IP" in your browser. Append /32 to the result to create a CIDR block that matches only your address.

You should see a summary ending with a line like "Plan: X to add, 0 to change, 0 to destroy" confirming your configuration is valid.

Plan showing errors?

  • If you see a missing closing brace error, count the { and } characters in your NACL resource. Each ingress/egress block needs its own pair.
  • If you see an error about rule_no, make sure each rule number is unique within the same direction (ingress or egress).

Help me fix my Terraform plan error.

Contrast stateful vs stateless in your code

Look at the two types of firewall you just wrote. The contrast between them is the most important networking concept in this step.

Stateful vs stateless: what's the difference?

Your security group has a single egress rule that allows all outbound traffic. It does not need specific rules for response packets because security groups remember the connection state. If inbound SSH is allowed, the outbound SSH response flows automatically.

Your NACL has explicit egress rules for ephemeral ports (1024-65535). Without these rules, TCP response packets would be silently dropped at the subnet boundary. The NACL evaluates every packet independently with no memory of prior traffic.

This is why the comments in your code say "STATEFUL: return traffic is automatically allowed" and "STATELESS: must explicitly allow return traffic." Missing NACL ephemeral port rules is the most common networking misconfiguration in production AWS.

Why use both security groups and NACLs?

Security groups only allow traffic (no deny rules). NACLs can explicitly deny specific IPs or ranges. Using both gives you defense in depth.

Security groups protect individual instances. NACLs protect the entire subnet. If one layer is misconfigured, the other still blocks unauthorized traffic.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point:

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

provider "aws" {
  region = var.aws_region
}

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

  tags = { Name = "my-lab-vpc" }
}

# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "my-lab-igw" }
}

# --- Subnets ---
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = false

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

# --- Elastic IP and NAT Gateway ---
resource "aws_eip" "nat" {
  domain = "vpc"
  tags   = { Name = "nat-eip" }
}

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

  tags = { Name = "my-lab-nat" }

  depends_on = [aws_internet_gateway.main]
}

# --- 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.main.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
}

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

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

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

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

# --- Security Groups ---

# Security groups are STATEFUL: return traffic is automatically allowed.
resource "aws_security_group" "public" {
  name        = "public-sg"
  description = "Allow SSH from my IP and HTTP from anywhere"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_security_group" "private" {
  name        = "private-sg"
  description = "Allow SSH from public subnet and HTTPS for endpoints"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from public subnet"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.public_subnet_cidr]
  }

  ingress {
    description = "HTTPS for VPC endpoints and AWS services"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

# --- Network ACL (STATELESS: must explicitly allow return traffic) ---
resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.public.id]

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

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

  ingress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  # Outbound rules - MUST explicitly allow responses (stateless!)
  egress {
    rule_no    = 100
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  egress {
    rule_no    = 110
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  egress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = var.private_subnet_cidr
    from_port  = 22
    to_port    = 22
  }

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

Your network now has two layers of security protecting traffic at both the instance and subnet levels. Next up, you will connect your private subnet directly to AWS services using VPC endpoints.

Add VPC Endpoints

Your network now has layered security with security groups and NACLs controlling traffic in and out. The next piece of the puzzle is how your private instances talk to AWS services like S3 and Systems Manager.

Right now, any traffic from your private subnet to S3 would route through the NAT gateway and out over the public internet. VPC endpoints let you keep that traffic entirely on the AWS backbone network, improving both security and performance.

In this step, get ready to:
  • Add a gateway endpoint for S3 (free, route-table based).
  • Add an interface endpoint for SSM (PrivateLink, ENI-based).
  • Write Terraform outputs to surface key resource IDs.
Add a gateway endpoint for S3

A gateway endpoint is a free, route-table-based connection that keeps traffic to S3 (or DynamoDB) on the AWS backbone. No network interface is created, and there is no hourly charge.

  • In main.tf, scroll to the bottom of the file (after your NACL resource).
  • Add the following VPC endpoint resource for S3:
# --- VPC Endpoints ---

# Gateway Endpoint for S3: FREE, route-table based, no ENI
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]

  tags = { Name = "s3-gateway-endpoint" }
}

What does this code do?

  • service_name uses your region variable to construct the correct S3 service address.
  • vpc_endpoint_type = "Gateway" tells AWS to add a prefix-list route to the specified route table instead of creating a network interface.
  • route_table_ids points to the private route table, so only the private subnet gets the direct S3 route.
  • Save main.tf.

You won't see a change yet. The next chunk adds the interface endpoint, and then you will verify both together.

Add an interface endpoint for SSM

An interface endpoint uses AWS PrivateLink to create an ENI (elastic network interface) with a private IP inside your subnet. When private_dns_enabled is true, the standard SSM DNS name resolves to this private IP instead of the public endpoint.

  • In main.tf, directly below the S3 endpoint you just added, add the SSM interface endpoint:
# Interface Endpoint for SSM: PrivateLink, creates ENI, costs money
resource "aws_vpc_endpoint" "ssm" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${var.aws_region}.ssm"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [aws_subnet.private.id]
  security_group_ids  = [aws_security_group.private.id]
  private_dns_enabled = true

  tags = { Name = "ssm-interface-endpoint" }
}

What does this code do?

  • vpc_endpoint_type = "Interface" tells AWS to create an ENI in your subnet rather than adding a route.
  • subnet_ids places the ENI in the private subnet where your instances will use it.
  • security_group_ids attaches your private security group, which already allows inbound HTTPS (port 443) from the VPC CIDR.
  • private_dns_enabled = true overrides the public DNS for the SSM service so traffic routes to the ENI automatically.
  • Save main.tf.

Seeing a DNS resolution error later?

Make sure enable_dns_support and enable_dns_hostnames are both true on your VPC resource. Private DNS for interface endpoints requires both.

help me debug DNS resolution for my VPC interface endpoint

Gateway vs interface: when to use each

Gateway endpoints are free, only work for S3 and DynamoDB, and add routes to your route table. Interface endpoints work for 100+ services, create ENIs in your subnets, cost $0.01/hour per AZ plus data processing, and support security groups.

The decision is straightforward: if a gateway endpoint is available (S3 or DynamoDB), use gateway. For everything else, use interface.

Write Terraform outputs

Outputs let Terraform display key resource IDs and values after terraform apply runs. You will use these to verify your infrastructure in the next step.

  • Open outputs.tf (currently empty) and add the following output definitions:
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 "nat_gateway_public_ip" {
  description = "Public IP of the NAT gateway"
  value       = aws_eip.nat.public_ip
}

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

output "ssm_endpoint_id" {
  description = "ID of the SSM interface endpoint"
  value       = aws_vpc_endpoint.ssm.id
}

What does this code do?

Each output block exposes a resource attribute to the terminal after Terraform creates it. nat_gateway_public_ip is especially useful because it shows the Elastic IP that all outbound private traffic will appear to come from.

  • Save outputs.tf.
  • Run terraform plan in your terminal to confirm both endpoints and outputs are valid.
terraform plan

You should see the two new aws_vpc_endpoint resources listed in the plan output alongside all previous resources.

Plan showing errors?

Check that aws_route_table.private.id and aws_security_group.private.id match the resource names from the previous step. A typo in the reference name is the most common cause.

help me fix Terraform plan errors for my VPC endpoints

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point:

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

provider "aws" {
  region = var.aws_region
}

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

  tags = { Name = "my-lab-vpc" }
}

# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "my-lab-igw" }
}

# --- Subnets ---
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = false

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

# --- Elastic IP and NAT Gateway ---
resource "aws_eip" "nat" {
  domain = "vpc"
  tags   = { Name = "nat-eip" }
}

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

  tags = { Name = "my-lab-nat" }

  depends_on = [aws_internet_gateway.main]
}

# --- 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.main.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
}

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

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

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

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

# --- Security Groups ---

# Security groups are STATEFUL: return traffic is automatically allowed.
resource "aws_security_group" "public" {
  name        = "public-sg"
  description = "Allow SSH from my IP and HTTP from anywhere"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_security_group" "private" {
  name        = "private-sg"
  description = "Allow SSH from public subnet and HTTPS for endpoints"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from public subnet"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.public_subnet_cidr]
  }

  ingress {
    description = "HTTPS for VPC endpoints and AWS services"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

# --- Network ACL (STATELESS: must explicitly allow return traffic) ---
resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.public.id]

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

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

  ingress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  # Outbound rules - MUST explicitly allow responses (stateless!)
  egress {
    rule_no    = 100
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  egress {
    rule_no    = 110
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  egress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = var.private_subnet_cidr
    from_port  = 22
    to_port    = 22
  }

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

# --- VPC Endpoints ---

# Gateway Endpoint for S3: FREE, route-table based, no ENI
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]

  tags = { Name = "s3-gateway-endpoint" }
}

# Interface Endpoint for SSM: PrivateLink, creates ENI, costs money
resource "aws_vpc_endpoint" "ssm" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${var.aws_region}.ssm"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [aws_subnet.private.id]
  security_group_ids  = [aws_security_group.private.id]
  private_dns_enabled = true

  tags = { Name = "ssm-interface-endpoint" }
}

Here is the complete 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
}

output "nat_gateway_public_ip" {
  description = "Public IP of the NAT gateway"
  value       = aws_eip.nat.public_ip
}

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

output "ssm_endpoint_id" {
  description = "ID of the SSM interface endpoint"
  value       = aws_vpc_endpoint.ssm.id
}

Your Terraform configuration is now complete with VPC endpoints connecting your private subnet directly to AWS services. Next up, you will apply the entire configuration and verify every resource in the AWS Console.

Apply and Verify

Your Terraform configuration is complete. You have defined a VPC, subnets, route tables, a NAT gateway, security groups, NACLs, and VPC endpoints across three files.

Now it's time to bring your network to life. In this step, you'll run terraform apply to create all resources in AWS, verify them in the console, and then destroy everything to keep costs minimal.

In this step, get ready to:
  • Run terraform apply to create all AWS resources.
  • Verify the network in the AWS Console VPC Dashboard.
  • Run terraform destroy to remove everything and stop charges.
Run terraform apply

Your Terraform code describes the desired state of your AWS network. The terraform apply command reads your .tf files, calculates what needs to be created, and provisions the resources in your AWS account.

You'll be prompted for your IP

The my_ip variable has no default value, so Terraform will ask you to enter it. You need your public IP address in CIDR notation (e.g. 203.0.113.1/32).

To find your public IP, search "what is my IP" in your browser. Copy the IP address and append /32 to restrict SSH access to only your machine.

  • Deploy your entire network by running this command:
terraform apply
  • When prompted for var.my_ip, enter your public IP in CIDR format (e.g. 203.0.113.1/32).
  • Terraform displays a plan showing all the resources it will create. Review the list.
  • Type yes and press Enter to confirm.

Terraform creates the resources in dependency order. This takes 2-3 minutes because the NAT gateway requires time to provision.

When it finishes, you should see the outputs printed in your terminal: vpc_id, public_subnet_id, private_subnet_id, nat_gateway_public_ip, s3_endpoint_id, and ssm_endpoint_id.

Seeing an error during apply?

  • If you see an "InvalidParameterValue" or credential error, confirm your AWS CLI is configured correctly by running aws sts get-caller-identity. You should see your account ID in the output.
  • If you see a "limit exceeded" error, your account may have hit a VPC or Elastic IP quota in the region. Try a different region by editing variables.tf or run terraform apply -var="aws_region=us-west-2".
  • help me debug my terraform apply error
Verify in the AWS Console

Seeing your infrastructure in the AWS Console confirms that your Terraform code produced the exact network you designed.

  • Open the AWS Console in your browser at https://console.aws.amazon.com.
  • Search for VPC in the top search bar and open the VPC Dashboard.
  • Click Your VPCs in the left sidebar. You should see my-lab-vpc with CIDR 10.0.0.0/16.
  • Click Subnets in the left sidebar. You should see public-subnet (10.0.1.0/24) and private-subnet (10.0.2.0/24).
  • Click Route tables. Select public-rt and check its routes tab. You should see a route to 0.0.0.0/0 targeting the internet gateway.
  • Select private-rt and check its routes tab. You should see a route to 0.0.0.0/0 targeting the NAT gateway, plus an S3 prefix-list route from the gateway endpoint.
  • Click Endpoints in the left sidebar. You should see both s3-gateway-endpoint (type Gateway) and ssm-interface-endpoint (type Interface).

What is the S3 prefix-list route?

When you created the S3 gateway endpoint, AWS automatically added a route to the private route table. This route uses a prefix list (a managed set of S3 IP ranges) as the destination instead of a CIDR block. Traffic matching those IPs goes directly to S3 over the AWS backbone, bypassing the NAT gateway entirely.

Run terraform destroy

The NAT gateway costs $0.045 per hour and the Elastic IP costs $0.005 per hour. Destroying your resources immediately after verification keeps total cost under $0.15.

  • Remove all resources by running this command:
terraform destroy
  • When prompted for var.my_ip, enter the same IP you used during apply (e.g. 203.0.113.1/32).
  • Type yes and press Enter to confirm destruction.

Terraform deletes the resources in reverse dependency order. This takes 2-3 minutes. You should see "Destroy complete" with a count of all destroyed resources.

Why destroy now?

Infrastructure as Code means you can recreate this entire network at any time with a single terraform apply. There is no reason to keep resources running when you're not actively using them. This is one of the biggest advantages of defining infrastructure in code.

Destroy stuck or failing?

  • If destroy hangs on the NAT gateway or interface endpoint, wait up to 5 minutes. These resources take time to deprovision.
  • If you see a dependency error, run terraform destroy again. Terraform retries the correct deletion order on subsequent runs.
  • help me fix my terraform destroy error

✔️ Awesome, I've got everything!

Great. Double check that you've saved all three files and that Terraform confirmed "Destroy complete" in your terminal.

ⓧ I'd like to double check the full code

Here is the complete main.tf:

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

provider "aws" {
  region = var.aws_region
}

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

  tags = { Name = "my-lab-vpc" }
}

# --- Internet Gateway ---
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
  tags   = { Name = "my-lab-igw" }
}

# --- Subnets ---
resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = true

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

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.private_subnet_cidr
  availability_zone       = "${var.aws_region}a"
  map_public_ip_on_launch = false

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

# --- Elastic IP and NAT Gateway ---
resource "aws_eip" "nat" {
  domain = "vpc"
  tags   = { Name = "nat-eip" }
}

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

  tags = { Name = "my-lab-nat" }

  depends_on = [aws_internet_gateway.main]
}

# --- 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.main.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
}

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

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

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

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

# --- Security Groups ---

# Security groups are STATEFUL: return traffic is automatically allowed.
resource "aws_security_group" "public" {
  name        = "public-sg"
  description = "Allow SSH from my IP and HTTP from anywhere"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }

  ingress {
    description = "HTTP from anywhere"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

resource "aws_security_group" "private" {
  name        = "private-sg"
  description = "Allow SSH from public subnet and HTTPS for endpoints"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from public subnet"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.public_subnet_cidr]
  }

  ingress {
    description = "HTTPS for VPC endpoints and AWS services"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.vpc_cidr]
  }

  egress {
    description = "All outbound traffic"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

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

# --- Network ACL (STATELESS: must explicitly allow return traffic) ---
resource "aws_network_acl" "public" {
  vpc_id     = aws_vpc.main.id
  subnet_ids = [aws_subnet.public.id]

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

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

  ingress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  # Outbound rules - MUST explicitly allow responses (stateless!)
  egress {
    rule_no    = 100
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 1024
    to_port    = 65535
  }

  egress {
    rule_no    = 110
    protocol   = "tcp"
    action     = "allow"
    cidr_block = "0.0.0.0/0"
    from_port  = 80
    to_port    = 80
  }

  egress {
    rule_no    = 120
    protocol   = "tcp"
    action     = "allow"
    cidr_block = var.private_subnet_cidr
    from_port  = 22
    to_port    = 22
  }

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

# --- VPC Endpoints ---

# Gateway Endpoint for S3: FREE, route-table based, no ENI
resource "aws_vpc_endpoint" "s3" {
  vpc_id            = aws_vpc.main.id
  service_name      = "com.amazonaws.${var.aws_region}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.private.id]

  tags = { Name = "s3-gateway-endpoint" }
}

# Interface Endpoint for SSM: PrivateLink, creates ENI, costs money
resource "aws_vpc_endpoint" "ssm" {
  vpc_id              = aws_vpc.main.id
  service_name        = "com.amazonaws.${var.aws_region}.ssm"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [aws_subnet.private.id]
  security_group_ids  = [aws_security_group.private.id]
  private_dns_enabled = true

  tags = { Name = "ssm-interface-endpoint" }
}

Here is the complete variables.tf:

variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC (/16 = 65,536 IPs)"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidr" {
  description = "CIDR block for the public subnet (/24 = 256 IPs)"
  type        = string
  default     = "10.0.1.0/24"
}

variable "private_subnet_cidr" {
  description = "CIDR block for the private subnet (/24 = 256 IPs)"
  type        = string
  default     = "10.0.2.0/24"
}

variable "my_ip" {
  description = "Your public IP for SSH access (CIDR notation, e.g. 203.0.113.1/32)"
  type        = string
}

Here is the complete 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
}

output "nat_gateway_public_ip" {
  description = "Public IP of the NAT gateway"
  value       = aws_eip.nat.public_ip
}

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

output "ssm_endpoint_id" {
  description = "ID of the SSM interface endpoint"
  value       = aws_vpc_endpoint.ssm.id
}

Secret mission

Launch a Private Instance with SSM Access

Your network is built, but how do you prove it actually works? Deploy an EC2 instance in the private subnet and connect to it via SSM Session Manager. No SSH keys, no public IP, no bastion host. Just PrivateLink doing its job.

Clean Up Your Resources

Clean Up Your Resources

This project creates AWS resources that cost money while they run. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Cost warning

Your NAT gateway costs $0.045 per hour and your Elastic IP costs $0.005 per hour. The interface endpoint for SSM also incurs an hourly charge.

If you leave these running, you will accumulate roughly $1 per day in charges. Run terraform destroy as soon as you finish verifying your network.

Resources you used:

  • NAT Gateway ($0.045/hour + $0.045/GB processed).
  • Elastic IP ($0.005/hour).
  • Interface endpoint for SSM (hourly + data processing charge).
  • VPC, subnets, route tables, security groups, NACLs, and gateway endpoint (free).
  • Local project files (vpc-terraform directory).
  • Terraform (installed via Homebrew).
  • AWS CLI (installed via Homebrew).

Keep everything running

No action needed. Choose this if you are still actively experimenting with your VPC and plan to continue within the next hour or two.

  • Be aware that your NAT gateway and Elastic IP continue to incur charges while they exist.
  • Terraform and the AWS CLI remain installed on your machine.
  • Your local vpc-terraform folder stays on your Desktop.

Pause - I'll come back to this later

AWS networking resources like VPCs, subnets, and route tables have no "stop" state. The only way to stop charges is to destroy the paid resources.

  • Run terraform destroy to remove all AWS resources and stop charges.
  • Keep your local vpc-terraform folder. Your .tf files are still there so you can terraform apply again whenever you want to recreate the network.
  • Keep Terraform and the AWS CLI installed so you are ready to redeploy.

Delete - I don't want to use this again

Remove all project resources and start fresh. This stops all charges and cleans up your machine.

  • Destroy all AWS resources by running this command inside your vpc-terraform folder:
terraform destroy
  • Type yes when prompted to confirm.
  • Verify in the AWS Console that no Elastic IPs or NAT gateways remain. Open the VPC Dashboard and check the NAT gateways and Elastic IPs pages.
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf vpc-terraform

On Windows, use this instead:

cd ~\Desktop
rmdir /s /q vpc-terraform
  • Uninstall Terraform by running:
brew uninstall terraform
  • Uninstall the AWS CLI by running:
brew uninstall awscli

That's a Wrap!

That's a Wrap!

Nice work! You just deployed a production-grade AWS network entirely from Terraform code.

You've learned how to:

  • Deployed a VPC with planned CIDR ranges, public and private subnets, an internet gateway, and a NAT gateway for outbound-only internet access.
  • Written layered security using stateful security groups (allow-only, instance-level) and stateless NACLs (allow/deny, subnet-level) with explicit ephemeral port handling.
  • Connected private subnets directly to AWS services using a free gateway endpoint for S3 and an interface endpoint (PrivateLink) for SSM, and destroyed everything with one command.
  • Secret Mission: Launched a private EC2 instance and connected via SSM Session Manager through PrivateLink, proving the entire network works without SSH keys or public IPs.

Ready to quiz yourself?