AWS Web Server with Terraform Modules
Provision a VPC, EC2 web server, and reusable Terraform module on AWS.
Introduction
30 Second Summary
Every website, API, and cloud service you use runs on infrastructure that someone had to set up. When teams need to create that infrastructure reliably and repeatedly, they use Terraform to define it in code instead of clicking through cloud consoles.
In this project, you will use Terraform to provision a complete web server environment on AWS, starting from a single configuration file and building up to a reusable module that deploys the entire stack in seconds.
What You'll Build
Run a single terraform apply command and watch an entire web server environment spin up on AWS, complete with its own private network, security rules, and a live web page you can visit from any browser.
By the end of this project, you'll have:
- A private network on AWS you built entirely from code, with its own VPC, public subnet, and internet gateway routing traffic to the outside world.
- A live "Hello from Terraform!" web page you can visit in your browser, served by an EC2 instance you provisioned entirely through Terraform code.
- A reusable Terraform module under modules/web_server/ that lets you redeploy the entire stack with different settings by changing a single variable.
- Secret Mission: Deploy a second instance of your module in a different AWS region using a provider alias.
Are there any prerequisites?
This project assumes you have an AWS account with Free Tier access and the AWS CLI installed and configured (aws configure). You should also have basic familiarity with Terraform concepts like init, plan, and apply.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your career.
Set Up Terraform and Your Project
Before you can provision anything on AWS, you need the right tools on your machine. Terraform is a command-line tool that reads your configuration files and turns them into real cloud resources.
In this step, you will install the Terraform CLI, set up a VS Code extension for syntax highlighting, and create the project files that define which cloud provider and region to use.
In this step, get ready to:
- Install the Terraform CLI and verify it is version 1.15 or higher.
- Create a project directory with main.tf, variables.tf, and outputs.tf.
- Initialize Terraform and download the AWS provider.
Install Terraform
Terraform is distributed as a single binary you install on your machine. Check whether you already have it by running the version command in your terminal.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check if Terraform is installed by running:
terraform -version
This project requires Terraform version 1.15 or higher.
✔️ I see version 1.15 or higher
Nice, Terraform is ready to go. Skip ahead to the next substep where you install the VS Code extension.
ⓧ I see an older version
You have Terraform installed, but it is too old for this project. Upgrade it using your platform's package manager.
macOS
- Upgrade Terraform to the latest version by running:
brew upgrade hashicorp/tap/terraform
- Verify the upgrade by running:
terraform -version
You should now see Terraform v1.15.8 or a compatible version.
Windows
- Upgrade Terraform to the latest version by running:
choco upgrade terraform
- Close and reopen your Command Prompt.
- Verify the upgrade by running:
terraform -version
You should now see Terraform v1.15.8 or a compatible version.
ⓧ Command not found
Terraform is not installed yet. Install it using your platform's package manager.
macOS
- Add the HashiCorp tap and install Terraform by running:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
- Verify the installation by running:
terraform -version
You should see Terraform v1.15.8 or a compatible version.
Seeing 'brew: command not found'?
You need Homebrew installed first. Visit brew.sh and follow the one-line install command, then try again.
help me install Homebrew on my Mac
Windows
- Install Terraform using Chocolatey by running:
choco install terraform
- Close and reopen your Command Prompt.
- Verify the installation by running:
terraform -version
You should see Terraform v1.15.8 or a compatible version.
Don't have Chocolatey?
You can also download the Terraform binary directly from the official install page. Download the Windows AMD64 zip, extract it, and add the folder containing terraform.exe to your system PATH.
help me install Terraform on Windows without Chocolatey
- Install the HashiCorp Terraform extension in VS Code so that .tf files get syntax highlighting and autocompletion.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Search for HashiCorp Terraform.
- Click Install on the extension by HashiCorp (extension ID: HashiCorp.terraform).
What does this extension do?
The HashiCorp Terraform extension adds syntax highlighting, autocompletion, and validation for .tf files. It makes spotting typos in your HCL configuration much easier.
Create your project directory and initial files
Every Terraform project starts with a directory containing .tf configuration files. You will create three files that follow the standard Terraform convention.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir terraform-web-server
cd terraform-web-server
- Create the three Terraform files by running:
touch main.tf variables.tf outputs.tf
- Confirm the files exist by running:
ls
You should see main.tf, variables.tf, and outputs.tf in the output.
Why three files?
This is the standard Terraform project structure. main.tf holds your resources and provider configuration. variables.tf declares input variables that make your configuration flexible. outputs.tf defines values Terraform prints after it finishes applying.
- Open the terraform-web-server folder in VS Code by running:
code .
'code' command not found?
If the code command is not recognized, open VS Code manually. Click File in the top menu bar, then Open Folder, and select the terraform-web-server folder on your Desktop.
help me set up the code command in my terminal
You should see main.tf, variables.tf, and outputs.tf in the VS Code Explorer sidebar with Terraform syntax highlighting.
- Click on main.tf in the Explorer sidebar to open it.
- Add the Terraform configuration block and AWS provider by pasting this code:
# Pin the minimum Terraform version and declare the AWS provider
terraform {
required_version = ">= 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# Configure the AWS provider to use the region variable
provider "aws" {
region = var.aws_region
}
What does this code do?
The terraform block sets two constraints. required_version = ">= 1.15" ensures anyone running this project has a compatible Terraform CLI. required_providers tells Terraform to download the AWS provider from the HashiCorp registry.
The provider "aws" block configures that provider. region = var.aws_region references a variable you will define next, so the region is not hardcoded.
- Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Click on variables.tf in the Explorer sidebar to open it.
- Define the AWS region variable by pasting this code:
# Declare the AWS region with a sensible default
variable "aws_region" {
description = "AWS region to deploy resources into"
type = string
default = "us-east-1"
}
What does this variable do?
This declares an input variable called aws_region with a default of us-east-1. When Terraform reads var.aws_region in main.tf, it uses this default unless you override it.
- Save variables.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Leave outputs.tf empty for now. You will add outputs in a later step once you have resources to reference.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files 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.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
Here is the complete variables.tf at this point:
variable "aws_region" {
description = "AWS region to deploy resources into"
type = string
default = "us-east-1"
}
The outputs.tf file should be empty.
Initialize Terraform and download the AWS provider
Terraform needs to download the AWS provider plugin before it can work with your configuration. The terraform init command reads your required_providers block and fetches everything it needs.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Initialize Terraform by running:
terraform init
Before you check the output, what do you expect this command to do? Think about what you told Terraform it needs in main.tf.
You should see output that includes Initializing provider plugins and Terraform has been successfully initialized. Terraform downloaded the AWS provider and created two new items in your project directory.
- Confirm the new files by running:
ls -a
You should see a .terraform directory and a .terraform.lock.hcl file alongside your three .tf files.
What did terraform init create?
The .terraform directory stores the downloaded provider plugins. The .terraform.lock.hcl lock file records the exact provider version and checksums so every team member uses the same version.
Seeing an error during init?
Make sure your terminal is inside the terraform-web-server folder. Run pwd to check your current directory.
If you see a credentials error, confirm you have previously run aws configure with valid credentials. The init step downloads the provider plugin but does not contact AWS, so credentials errors are rare here.
help me troubleshoot my terraform init error
Your tools are installed and your project skeleton is ready. Next up, you will write real infrastructure code to deploy a VPC and an EC2 instance on AWS.
Deploy a VPC and EC2 Instance
Your Terraform project is initialized and ready to provision real infrastructure. In this step, you'll define a network and a web server on AWS, then deploy it all with a single command.
You'll build the core networking layer first: a VPC and a subnet. Then you'll add a security group and an EC2 instance running a simple web page.
In this step, get ready to:
- Add a VPC and subnet to create your network.
- Add a security group and EC2 instance to host your web server.
- Deploy the infrastructure and test access to your web server.
Add data sources, a VPC, and a subnet
Before creating resources, Terraform needs to look up two pieces of information from AWS: which availability zones are available in your region, and which AMI (machine image) to use for the server.
- In VS Code, open main.tf.
- Add these two data sources below the provider "aws" block by copying this code:
# Look up the available availability zones in the current region
data "aws_availability_zones" "available" {
state = "available"
}
# Find the latest Amazon Linux 2023 AMI automatically
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
}
What does this code do?
Data sources let Terraform query AWS for information without creating anything.
- aws_availability_zones fetches the list of AZs in your region so you can pick one dynamically instead of hardcoding it.
- aws_ami finds the latest Amazon Linux 2023 image. Using a filter with most_recent = true means Terraform always picks the newest matching AMI, so you never hardcode a region-specific image ID.
Now add the networking resources. A VPC is your isolated network, and a subnet is a range of IP addresses inside it.
- Add this code below the data sources you just added:
# Create a VPC with DNS hostname support
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
}
# Create a public subnet inside the VPC
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
}
What does this code do?
- aws_vpc creates a virtual private cloud with the CIDR block 10.0.0.0/16, giving you 65,536 possible IP addresses. enable_dns_hostnames lets EC2 instances in this VPC receive public DNS names.
- aws_subnet carves out a smaller range (10.0.1.0/24, 256 addresses) inside the VPC. map_public_ip_on_launch = true ensures any instance launched here automatically gets a public IP address.
- Save main.tf.
- Preview what Terraform will create by running this command in your terminal:
terraform plan
You should see Terraform planning to create 2 resources (the VPC and subnet) and read 2 data sources.
Seeing an error?
- Check that your AWS CLI credentials are still configured. Run aws sts get-caller-identity to verify you can connect to AWS.
- Make sure you saved main.tf before running the command.
Still stuck? Help me debug my terraform plan error
Add the security group and EC2 instance
Your network is defined, but you need two more pieces: a security group to control which traffic can reach the server, and the EC2 instance itself.
- In main.tf, add this security group below the aws_subnet resource:
# Create a security group to control traffic to the web server
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow HTTP and SSH inbound traffic"
vpc_id = aws_vpc.main.id
# Allow HTTP traffic from anywhere
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Allow SSH traffic from anywhere
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Allow all outbound traffic
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
What does this code do?
A security group acts as a virtual firewall. This one defines three rules:
- Port 80 (HTTP) is open so browsers can reach the web page.
- Port 22 (SSH) is open so you could connect to the instance for debugging.
- All outbound traffic is allowed so the instance can download software updates.
Lock down SSH in production
Opening SSH to 0.0.0.0/0 (the entire internet) is fine for learning. In a real environment, restrict it to your own IP address.
You can find your public IP by searching "what is my IP" in your browser.
- Now add the EC2 instance below the security group:
# Launch an EC2 instance running a simple web server
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t2.micro"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
# Install and start Apache HTTP Server with a test page
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Terraform!</h1>" > /var/www/html/index.html
EOF
}
What does this code do?
- ami references the Amazon Linux 2023 image your data source found.
- instance_type is set to t2.micro, which is covered by the AWS Free Tier.
- vpc_security_group_ids attaches the firewall rules you just defined.
- user_data is a bash script that runs automatically when the instance first boots. It installs Apache HTTP Server, starts it, and writes a simple HTML page.
- Save main.tf.
- Preview the full plan by running:
terraform plan
You should see Terraform planning to create 4 resources: the VPC, subnet, security group, and EC2 instance.
Seeing a syntax error?
- Check that the user_data heredoc uses <<-EOF (with a hyphen) and that EOF appears on its own line at the end.
- Make sure every opening brace { has a matching closing brace }.
Still stuck? Help me fix a syntax error in my Terraform security group or EC2 instance code
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.tf before continuing.
ⓧ I'd like to double check the full code
Your main.tf should contain these blocks in order. Scroll up and confirm each one is present:
- The terraform block with required_version and required_providers (from Step 1).
- The provider "aws" block (from Step 1).
- data "aws_availability_zones" "available" (data source).
- data "aws_ami" "amazon_linux" (data source with filter).
- resource "aws_vpc" "main" (VPC).
- resource "aws_subnet" "public" (subnet).
- resource "aws_security_group" "web" (security group with 3 rules).
- resource "aws_instance" "web" (EC2 instance with user_data).
Deploy and test your infrastructure
Your configuration defines a VPC, subnet, security group, and EC2 instance. You also need an output so Terraform prints the instance's public IP after it deploys.
- Open outputs.tf in VS Code and add this output block:
output "instance_public_ip" {
description = "Public IP address of the web server"
value = aws_instance.web.public_ip
}
What does this code do?
An output block tells Terraform to print a value after it finishes applying. Here it prints the EC2 instance's public IP so you can open it in a browser without digging through the AWS Console.
- Save outputs.tf.
✔️ 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
Your outputs.tf should look exactly like this:
output "instance_public_ip" {
description = "Public IP address of the web server"
value = aws_instance.web.public_ip
}
Now it's time to deploy everything to AWS.
- Deploy your infrastructure by running:
terraform apply
What happens during terraform apply?
Terraform shows you the full execution plan first, just like terraform plan. It then waits for you to confirm before making any changes.
- Terraform will display the plan and ask Do you want to perform these actions? Type yes and press Enter.
This takes about a minute. When it finishes, you should see Apply complete! Resources: 4 added, 0 changed, 0 destroyed. followed by the instance_public_ip output value.
Apply failed?
- If you see an error about credentials or authorization, run aws sts get-caller-identity to verify your AWS CLI is configured correctly.
- If you see an error about an invalid AMI, make sure the filter block in your data "aws_ami" uses al2023-ami-2023.*-x86_64 exactly.
Still stuck? Help me debug my terraform apply failure when deploying a VPC and EC2 instance
Your EC2 instance is running and has a public IP. Before you move on, do you think you can visit that IP in your browser and see the web page?
- Copy the instance_public_ip value from the terminal output.
- Open a new browser tab and go to http:// followed by the IP address you copied.
The page does not load. Your browser spins and eventually times out.
Why does it time out?
Your EC2 instance has a public IP, but the VPC has no route to the internet. Every VPC starts with a default route table that only contains a local route for traffic within the VPC.
For traffic from the internet to reach your instance, you need two things: an internet gateway attached to the VPC, and a route table entry that sends 0.0.0.0/0 traffic through that gateway. This is the most common VPC networking mistake on AWS.
You have deployed real infrastructure on AWS, but there is a networking gap to fix. In the next step, you will add the missing internet gateway and route table so your web server becomes reachable.
Fix the Networking
In the last step, you deployed a VPC, subnet, security group, and EC2 instance. You even got a public IP for the instance. But when you tried to load the page in your browser, it timed out.
The problem is that your VPC has no path to the internet. Every VPC starts with a default route table that only routes traffic within the VPC itself. To let traffic flow between your instance and the outside world, you need two things: an internet gateway (the door to the internet) and a route table entry that points outbound traffic through that door.
In this step, get ready to:
- Add an internet gateway and route table to the VPC.
- Connect the subnet to the new route table.
- Verify the web server is accessible from your browser.
Add an internet gateway and route table
Right now, your VPC's default route table only knows how to route traffic within the 10.0.0.0/16 CIDR range. Traffic destined for the public internet has nowhere to go. An internet gateway and a custom route table fix that.
- In main.tf, find the closing } of the aws_subnet "public" resource block.
- Add the following code below it to create an internet gateway and a route table:
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
# Send all non-local traffic through the internet gateway
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
What do these resources do?
The internet gateway connects your VPC to the public internet. Without it, nothing inside your VPC can send or receive traffic outside the VPC's own CIDR range.
The route table has one route entry: any traffic headed to 0.0.0.0/0 (anywhere outside the VPC) gets sent through the internet gateway. This is what makes a subnet "public."
- Save main.tf. The internet gateway and route table are defined, but they still need to be linked to your subnet. The next substep completes that connection.
Connect the subnet and preview the plan
A route table on its own does nothing. It has to be explicitly associated with the subnet you want to make public. You also need to tell Terraform that the EC2 instance depends on the internet gateway, so the gateway is ready before the instance tries to reach the internet.
- In main.tf, find the aws_route_table "public" block you just added.
- Add the following code below it to associate the route table with your subnet:
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
What does this resource do?
Every subnet in AWS uses the VPC's main route table by default. That default table only has a local route. By creating this explicit association, your public subnet now uses the route table that has the internet gateway route.
There is one more change to make. The EC2 instance needs to know it depends on the internet gateway.
- Scroll down to the aws_instance "web" resource block.
- Find the closing lines of the block, which look like this:
EOF
}
- Add a blank line after EOF, then add a depends_on line so the end of the resource looks like this:
EOF
depends_on = [aws_internet_gateway.main]
}
Why add depends_on?
Terraform builds resources in parallel by default. Without depends_on, Terraform might try to launch the EC2 instance before the internet gateway is fully created. The depends_on tells Terraform to finish the internet gateway first.
- Save main.tf.
- Preview the changes Terraform will make by running this command in your terminal:
terraform plan
Terraform shows 3 resources to add: the internet gateway, route table, and route table association. Your existing resources (VPC, subnet, security group, EC2 instance) are unchanged.
Seeing errors in the plan?
- Check that the internet gateway, route table, and route table association blocks are placed between the aws_subnet "public" and aws_security_group "web" blocks in main.tf.
- Make sure every resource block has matching opening and closing braces {}. A missing brace can cascade errors across the whole file.
- Verify that depends_on = [aws_internet_gateway.main] is inside the aws_instance "web" block, not outside it.
- Still stuck? Help me debug my terraform plan errors for the internet gateway and route table.
Apply the changes and verify your web server
The plan looks good. Before you apply it, think about what you expect to happen. The browser timed out last time because there was no route to the internet. You have now added an internet gateway and a route that points 0.0.0.0/0 traffic through it. Do you think the page will load this time?
- Apply the networking changes by running:
terraform apply
- Type yes when Terraform asks you to confirm.
Terraform creates 3 new resources. Your existing VPC, subnet, security group, and EC2 instance are untouched.
- Copy the instance_public_ip value from the Terraform output.
- Open your browser and go to http:// followed by the IP address you copied.
You should see a page that says "Hello from Terraform!" This is the same instance that timed out before. The only difference is that it now has a route to the internet.
Why did this fix work?
Your EC2 instance already had a public IP from map_public_ip_on_launch. The security group already allowed port 80 traffic. The only missing piece was a route from the subnet to the internet. Adding the internet gateway, route table, and association completed the path.
This is the single most common VPC networking mistake on AWS. A public IP without a route to the internet is like putting a mailbox on a house with no road leading to it.
Page still timing out?
- Make sure you are using http:// and not https://. Your security group only allows port 80 (HTTP), not port 443 (HTTPS). Some browsers auto-redirect to HTTPS.
- Confirm the instance_public_ip output matches the IP you pasted into the browser. Run terraform output to double check.
- The EC2 instance may need a minute to finish running its startup script. Wait 30 seconds and try refreshing.
- Still stuck? Help me debug why my Terraform EC2 web server is not reachable after adding the internet gateway.
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.tf and that the "Hello from Terraform!" page loads in your browser.
ⓧ I'd like to double check the full code
Your main.tf is long at this point, so scroll through it and confirm these blocks are present in order:
- terraform block with required_version and required_providers.
- provider "aws" block with region = var.aws_region.
- data "aws_availability_zones" "available" and data "aws_ami" "amazon_linux" data sources.
- aws_vpc "main" and aws_subnet "public" resources (from Step 2).
- aws_internet_gateway "main" (new in this step).
- aws_route_table "public" with a route to 0.0.0.0/0 (new in this step).
- aws_route_table_association "public" (new in this step).
- aws_security_group "web" (from Step 2).
- aws_instance "web" with depends_on = [aws_internet_gateway.main] at the bottom of the block (new in this step).
The variables.tf and outputs.tf files are unchanged from Step 2.
Your web server is live and serving traffic from a custom VPC that you built from scratch. Next up, you will refactor this entire configuration into a reusable Terraform module that you can deploy with different settings in seconds.
Refactor Into a Terraform Module
Your web server is live and reachable. The networking gap from Step 3 is fixed, and you have a working stack of seven AWS resources.
There is a problem with how this configuration is organized. Every resource is hardcoded in a single main.tf file, which means you would have to copy and edit the entire file every time you want to deploy another web server. Production teams package infrastructure into reusable Terraform modules with input variables and outputs. In this step, you will refactor your configuration into that module pattern.
In this step, get ready to:
- Create a modules/web_server/ directory with main.tf, variables.tf, and outputs.tf.
- Move all resources into the module and replace hardcoded values with variables.
- Wire the root configuration to use your new module and redeploy.
Create the module files and add resources
The standard Terraform module structure uses three files: main.tf for resources, variables.tf for inputs, and outputs.tf for values the module exposes. You will create this structure now.
- Create the module directory and its three files by running these commands:
mkdir -p modules/web_server
touch modules/web_server/main.tf modules/web_server/variables.tf modules/web_server/outputs.tf
- Confirm the files were created by running:
ls modules/web_server/
You should see main.tf, outputs.tf, and variables.tf listed in the output.
Command not found?
Make sure your terminal is in the terraform-web-server directory. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.
Help me create the modules/web_server directory structure in my Terraform project.
Now you will populate the module's main.tf with all the resources currently in your root main.tf. Along the way, you will replace hardcoded values with variable references and add tags to every resource.
- Open modules/web_server/main.tf in VS Code and paste the data sources and VPC resource:
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
ManagedBy = "Terraform"
}
}
What changed from the root version?
- The data sources are identical to what you had before. They dynamically find the availability zone and AMI, so no changes are needed.
- The VPC's cidr_block now uses var.vpc_cidr instead of a hardcoded string. This lets you deploy VPCs with different address ranges.
- A tags block has been added using var.environment to label every resource with its environment. This is how teams tell apart dev, staging, and prod resources in the AWS console.
- Add the subnet and internet gateway below the VPC resource:
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.subnet_cidr
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.environment}-public-subnet"
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.environment}-igw"
Environment = var.environment
ManagedBy = "Terraform"
}
}
What does this code do?
- The subnet's cidr_block now uses var.subnet_cidr so you can assign different address ranges per deployment.
- Both resources get environment-based tags, just like the VPC.
- Add the route table and route table association below the internet gateway:
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 = "${var.environment}-public-rt"
Environment = var.environment
ManagedBy = "Terraform"
}
}
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 gets tags, but its routing logic is unchanged. The route table association is a simple link between two resources, so it has no tags.
- Add the security group below the route table association:
resource "aws_security_group" "web" {
name = "${var.environment}-web-sg"
description = "Allow HTTP and SSH inbound traffic"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH from anywhere"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.environment}-web-sg"
Environment = var.environment
ManagedBy = "Terraform"
}
}
What changed in the security group?
- The name now includes var.environment so each deployment gets a uniquely named security group (e.g. dev-web-sg).
- Each ingress and egress rule now has a description field. These descriptions appear in the AWS console and help teammates understand what each rule allows.
- Add the EC2 instance as the final resource in modules/web_server/main.tf:
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from Terraform!</h1><p>Environment: ${var.environment}</p>" > /var/www/html/index.html
EOF
tags = {
Name = "${var.environment}-web-server"
Environment = var.environment
ManagedBy = "Terraform"
}
depends_on = [aws_internet_gateway.main]
}
What changed in the EC2 instance?
- The instance_type now uses var.instance_type so you can resize the server without editing the module.
- The user_data script now includes ${var.environment} in the HTML so the web page shows which environment it belongs to.
- The depends_on is kept so Terraform still waits for the internet gateway before launching the instance.
- Save modules/web_server/main.tf.
Now define the input variables your module expects. These are the knobs that let callers customize a deployment.
- Open modules/web_server/variables.tf and paste the following variable declarations:
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
default = "10.0.1.0/24"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "environment" {
description = "Deployment environment name used for resource tagging"
type = string
}
Why does environment have no default?
The first three variables have sensible defaults so the module works out of the box. The environment variable has no default, which makes it required. Anyone calling this module must explicitly choose an environment name. This prevents accidental deployments with no label.
- Save modules/web_server/variables.tf.
Wire up the module outputs and root configuration
A module's outputs are how values like the public IP make it out to the root configuration. Without them, Terraform keeps the values locked inside the module.
- Open modules/web_server/outputs.tf and paste these output declarations:
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "public_ip" {
description = "Public IP address of the EC2 web server"
value = aws_instance.web.public_ip
}
What does this code do?
Each output exposes one value from inside the module. The root configuration accesses these with the module.web.<output_name> syntax. Without these declarations, the root cannot read the VPC ID, instance ID, or public IP.
- Save modules/web_server/outputs.tf.
Now update the root main.tf. All the resource and data source blocks need to be removed and replaced with a single module block.
- Open the root main.tf (in your terraform-web-server folder, not the one inside modules/).
- Delete everything below the provider "aws" block. This removes all the data and resource blocks you moved into the module.
- Add the following module block after the provider block:
module "web" {
source = "./modules/web_server"
environment = "dev"
}
What does this code do?
- The source argument tells Terraform where to find the module's code. A local path like ./modules/web_server points to the directory you just created.
- The environment = "dev" line passes a value to the required environment variable. The other variables (vpc_cidr, subnet_cidr, instance_type) use their defaults.
- Save main.tf.
The root main.tf should now contain only the terraform block, the provider block, and the module block. Nothing else.
Finally, update the root outputs.tf to read values from the module instead of referencing resources directly.
- Open the root outputs.tf and replace its entire content with:
output "public_ip" {
description = "Public IP address of the web server"
value = module.web.public_ip
}
output "vpc_id" {
description = "ID of the VPC"
value = module.web.vpc_id
}
output "instance_id" {
description = "ID of the EC2 instance"
value = module.web.instance_id
}
What changed here?
- The old output referenced aws_instance.web.public_ip directly. That resource now lives inside the module, so the reference becomes module.web.public_ip.
- Two new outputs (vpc_id and instance_id) surface additional module values that are useful for debugging and connecting other infrastructure.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files: root main.tf, root outputs.tf, modules/web_server/main.tf, modules/web_server/variables.tf, and modules/web_server/outputs.tf.
ⓧ I'd like to double check the full code
Here is each file in its final state. Scroll through and confirm yours matches.
Root main.tf:
terraform {
required_version = ">= 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}
module "web" {
source = "./modules/web_server"
environment = "dev"
}
Root outputs.tf:
output "public_ip" {
description = "Public IP address of the web server"
value = module.web.public_ip
}
output "vpc_id" {
description = "ID of the VPC"
value = module.web.vpc_id
}
output "instance_id" {
description = "ID of the EC2 instance"
value = module.web.instance_id
}
Root variables.tf (unchanged):
variable "aws_region" {
description = "AWS region to deploy resources into"
type = string
default = "us-east-1"
}
modules/web_server/variables.tf:
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
default = "10.0.1.0/24"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "environment" {
description = "Deployment environment name used for resource tagging"
type = string
}
modules/web_server/outputs.tf:
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web.id
}
output "public_ip" {
description = "Public IP address of the EC2 web server"
value = aws_instance.web.public_ip
}
modules/web_server/main.tf is too long to inline here. Scroll up and confirm you have all five chunks in order: data sources + VPC, subnet + internet gateway, route table + association, security group, and EC2 instance.
Reinitialize and apply the refactored configuration
Adding a new module source means Terraform needs to re-initialize before it can plan or apply.
- Register the new module by running:
terraform init
You should see a message that includes Initializing modules... followed by the module source path. This confirms Terraform found your module.
Seeing an error about missing variables or modules?
- Check that modules/web_server/variables.tf exists and declares all four variables (vpc_cidr, subnet_cidr, instance_type, environment).
- Confirm the source in your module block is exactly ./modules/web_server with the leading ./.
- Make sure the root main.tf no longer contains any resource or data blocks. Those should only exist in the module.
Help me fix a terraform init error after refactoring into a module.
Before you apply, think about this: you moved the resources into a module, but you did not delete them from AWS. What do you think terraform plan will show?
- Preview the changes by running:
terraform plan
Why does Terraform want to destroy and recreate?
The plan shows resources being destroyed and recreated. This is expected. Terraform tracks resources by their address in state. When you moved the VPC from root-level to a module, its address changed from aws_vpc.main to module.web.aws_vpc.main.
Terraform sees the old address as "deleted" and the new address as "new", so it plans to destroy the old resources and create new ones. The infrastructure is identical. Only the state tracking address changed.
- Apply the changes by running:
terraform apply
- Type yes when prompted to confirm the apply.
Terraform will destroy the old resources and create new ones under the module addresses. This takes a couple of minutes because it provisions a fresh EC2 instance.
What should I see?
The apply output should end with Apply complete! followed by the three outputs: instance_id, public_ip, and vpc_id. The public_ip will be a new IP address because the EC2 instance was recreated.
- Copy the new public_ip value from the output.
- Open your browser and navigate to http://<your-public-ip>, replacing <your-public-ip> with the IP you copied.
You should see "Hello from Terraform!" with "Environment: dev" displayed below it. The environment label confirms your module variable is flowing all the way through to the web page.
Page not loading?
- Make sure you are using http:// and not https://. The server does not have TLS configured.
- The EC2 instance needs a minute or two after creation for the user_data script to finish installing and starting Apache. Wait 60 seconds and refresh.
- Double check that the public_ip output printed by terraform apply is the IP you pasted in the browser. The IP changed after the recreate.
Help me troubleshoot why my Terraform module web server is not reachable.
Your entire web server stack is now packaged as a reusable module. You could deploy a second copy with different settings just by adding another module block. Next up, you will tear everything down cleanly with a single command.
Secret mission
Multi-Region Deployment with Provider Aliases
Your module works in one region. Can you deploy the exact same infrastructure to a second AWS region with just a few lines of configuration? This is the pattern production teams use for high availability and disaster recovery.
Clean Up Your Resources
Clean Up Your Resources
If you completed Step 5, your AWS resources are already destroyed. Decide whether to keep your local project files for future reference or delete them entirely.
Cost warning
If you redeployed resources after Step 5 (for example, during the Secret Mission) and did not run terraform destroy again, you may have running EC2 instances. The t2.micro instance is free-tier eligible for 12 months of new AWS accounts (750 hours/month), but exceeding that limit will incur charges.
Run terraform destroy from your terraform-web-server folder to remove all cloud resources immediately.
Resources you used:
- AWS resources managed by Terraform: VPC, subnet, internet gateway, route table, route table association, security group, and EC2 instance (created per module instance).
- Local terraform-web-server project folder on your Desktop.
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your Terraform module or want to reference the code later.
- Your local project files stay inside terraform-web-server on your Desktop.
- If you redeployed AWS resources and want to keep them running, the t2.micro instance is free-tier eligible. Monitor your usage in the AWS Billing dashboard.
- Terraform CLI and the HashiCorp Terraform VS Code extension remain installed on your machine.
Pause - I'll come back to this later
Free up compute costs while keeping your files intact for later.
- If you have running AWS resources, open the EC2 dashboard in the AWS Console, select your instance, then choose Instance state and click Stop instance.
- A stopped instance does not incur compute charges, but the public IP will change when you restart it (there is no Elastic IP attached).
- Your local project files and Terraform state remain untouched.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is the recommended option.
- If you have any AWS resources still running, navigate to your terraform-web-server folder and run the following command:
terraform destroy
- Type yes when prompted to confirm. Terraform will remove all VPC, subnet, internet gateway, route table, security group, and EC2 resources.
- Verify the state is empty by running:
terraform show
If the output is empty, all AWS resources have been successfully removed.
- Delete the local project folder. On macOS or Linux, run:
rm -rf ~/Desktop/terraform-web-server
- On Windows, run:
Remove-Item -Recurse -Force $HOME\Desktop\terraform-web-server
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir $HOME\Desktop (Windows). You should no longer see terraform-web-server in the output.
Nice Work!
Nice Work!
Nice work! You just provisioned a complete web server environment on AWS using Terraform, diagnosed a real networking failure, and packaged the entire stack into a reusable module.
You've learned how to:
- Build a custom VPC with a public subnet, internet gateway, and route table using HCL, and deploy an EC2 web server accessible from your browser via its public IP.
- Diagnose and fix a real networking problem where a server deploys with a public IP but is unreachable because the VPC has no route to the internet.
- Refactor a flat Terraform configuration into a reusable Terraform module with variables and outputs, following the standard module structure (main.tf, variables.tf, outputs.tf) used by production teams.
- Secret Mission: Deployed the same module to a second AWS region using provider aliases, demonstrating a multi-region deployment pattern.
Ready to quiz yourself?