Deploy a Web App on AWS with Terraform
Provision a live nginx web server on AWS using reusable Terraform modules.
Introduction
30 Second Summary
Every time you set up a server by clicking through a cloud console, you create a one-off that nobody can reliably repeat. If that server breaks at 2 AM, rebuilding it means retracing every manual step from memory.
In this project, you will practice Infrastructure as Code by using Terraform to define your server infrastructure in configuration files and deploy a live web application on AWS. You will then refactor everything into a reusable module that any team could use to spin up identical environments.
What You'll Build
When you finish, you will paste a public IP address into your browser and see a custom web page running on a server that Terraform provisioned entirely from your code.
By the end of this project, you'll have:
- A live web server on AWS you can visit in your browser, running inside a custom VPC with networking that Terraform built from your code.
- The confidence to preview any infrastructure change before it happens, using Terraform's plan/apply cycle to catch surprises before they reach your cloud.
- A reusable Terraform module that packages your entire web server setup for any team to deploy with different settings in one command.
- An optional Secret Mission to push your skills further.
Are there any prerequisites?
This project assumes you have an AWS account with the AWS CLI configured, Terraform CLI installed, and VS Code ready. Step 1 will verify your setup before you start building.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Understanding the goal upfront makes every Terraform command in the steps ahead click into place.
Initialize Your Terraform Project
Terraform doesn't know anything about AWS out of the box. It needs a provider configuration that tells it which cloud to connect to and which version of the provider plugin to download.
Without running terraform init, every other Terraform command fails. This step gets that foundation in place.
In this step, get ready to:
- Create the terraform-web-app project directory and open it in VS Code.
- Write the AWS provider configuration in providers.tf.
- Run terraform init to download the AWS provider plugin.
Create your project directory and open it in VS Code
Every Terraform project lives in its own directory. The directory holds your configuration files and the state Terraform uses to track your resources.
- In VS Code, open the integrated terminal by pressing Ctrl+`.
- 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-app
cd terraform-web-app
- Confirm you're in the right directory by running:
pwd
You should see a path ending in terraform-web-app.
Now open this folder as your VS Code workspace so the Explorer sidebar and terminal stay rooted here.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the terraform-web-app folder, and click Open.
Seeing a trust dialog?
VS Code may ask whether you trust the authors of the files in this folder. Click Yes, I trust the authors to continue.
- Reopen the terminal by pressing Ctrl+`.
The VS Code Explorer sidebar should show terraform-web-app as the workspace name, and the terminal prompt should be inside that folder.
Terminal not inside terraform-web-app?
If the terminal prompt shows a different directory, run cd ~/Desktop/terraform-web-app to move into the project folder.
If the Explorer sidebar doesn't show terraform-web-app, repeat the File > Open Folder step and make sure you select the correct folder on your Desktop.
Help me set up my VS Code workspace for a Terraform project
Write the AWS provider configuration
Terraform uses a providers.tf file to know which cloud provider to connect to. The required_providers block pins a specific provider version so your configuration stays reproducible.
- In the VS Code Explorer sidebar, right-click in the empty area and select New File.
- Name the file providers.tf and press Enter.
- Copy the following code into providers.tf:
# Require a minimum Terraform version and declare the AWS provider plugin
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# Configure the AWS provider with your target region
provider "aws" {
region = "us-east-1"
}
What does this code do?
- required_version = ">= 1.15.0" ensures anyone running this project has Terraform 1.15.0 or newer.
- source = "hashicorp/aws" tells Terraform to download the AWS provider plugin from the official HashiCorp registry.
- version = "~> 6.0" allows any 6.x release but blocks 7.0, keeping your configuration stable across minor updates.
- region = "us-east-1" sets the AWS region where all your resources will be created.
- Save providers.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
You also need an empty main.tf file. This is where your resource definitions will go starting in the next step.
- Right-click in the Explorer sidebar again and select New File.
- Name it main.tf and press Enter.
- Leave main.tf empty for now.
In the Explorer sidebar, you should now see both main.tf and providers.tf listed under terraform-web-app.
✔️ Awesome, I've got everything!
Great. Double check you've saved providers.tf. A white dot on the file tab means unsaved changes.
ⓧ I'd like to double check the full code
Here is the complete providers.tf file. Make sure yours matches exactly.
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
main.tf should be empty. You'll add resources to it in the next step.
Don't see both files in the sidebar?
Make sure you created the files inside the terraform-web-app folder, not a parent directory. The Explorer sidebar title at the top should read TERRAFORM-WEB-APP.
Check that your file names end in .tf, not .tf.txt. Windows sometimes appends a hidden extension.
Help me set up my Terraform project files in VS Code
Initialize Terraform
terraform init reads your provider configuration, downloads the required plugins, and sets up the working directory. Until you run this command, Terraform cannot execute any other commands.
Before you run the next command, what do you think terraform init will do based on the provider configuration you wrote in providers.tf?
- Initialize your Terraform project by running this command:
terraform init
You should see Terraform has been successfully initialized! in the output. Terraform downloaded the AWS provider plugin and created a .terraform/ directory to store it.
What just happened?
terraform init created two things in your project directory.
- .terraform/ is a hidden directory where Terraform caches the downloaded provider plugins. You never edit anything in here.
- .terraform.lock.hcl is a lock file that records the exact provider version Terraform downloaded. This ensures everyone on a team uses the same version.
- Confirm your Terraform version by running:
terraform version
You should see a version starting with 1.15. The output also shows the AWS provider version that was downloaded (a 6.x release matching your ~> 6.0 constraint).
Seeing an error from terraform init?
- If you see a message about missing credentials or authentication, check that your AWS CLI is configured correctly. Run aws sts get-caller-identity to verify your AWS credentials are working.
- If you see a syntax error, open providers.tf and compare it character-by-character against the double-check tab above. Missing braces or quotes are the most common cause.
- If you see a message about the required version, confirm you have Terraform 1.15.0 or newer installed by running terraform version.
Help me fix my terraform init error
Your Terraform project is initialized and connected to AWS. Next up, you'll deploy your first resource and see the plan/apply cycle in action.
Deploy Your First Resource and Learn the Plan/Apply Cycle
Your Terraform project is initialized and connected to AWS. Now it's time to deploy a real cloud resource.
Terraform's plan/apply cycle lets you preview every change before it touches your infrastructure. In this step, you'll experience this firsthand by deploying an S3 bucket.
In this step, get ready to:
- Define an S3 bucket resource in main.tf.
- Run terraform plan and terraform apply to create the bucket.
- Examine the Terraform state file to see how Terraform tracks your infrastructure.
Define your S3 bucket resource
Every piece of infrastructure in Terraform starts as a resource block in your configuration files. Each block specifies the resource type and its settings.
- Add your first resource to main.tf by pasting the following code:
# Create an S3 bucket with a globally unique name
resource "aws_s3_bucket" "app_assets" {
bucket = "terraform-web-app-assets-[[UNIQUE_SUFFIX="your-name-and-todays-date"]]"
tags = {
Project = "terraform-web-app"
ManagedBy = "terraform"
}
}
What does this code do?
- resource "aws_s3_bucket" "app_assets" declares an S3 bucket. aws_s3_bucket is the resource type and app_assets is the name Terraform uses to track it internally.
- bucket sets the actual S3 bucket name in AWS. S3 bucket names are globally unique across all AWS accounts, which is why you add your name and date.
- tags add metadata labels so you can identify this bucket later in the AWS console.
- Save main.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then move on to running the plan.
ⓧ I'd like to double check the full code
Here's the full main.tf at this point. Compare yours against it:
# Create an S3 bucket with a globally unique name
resource "aws_s3_bucket" "app_assets" {
bucket = "terraform-web-app-assets-[[UNIQUE_SUFFIX="your-name-and-todays-date"]]"
tags = {
Project = "terraform-web-app"
ManagedBy = "terraform"
}
}
Run the plan/apply cycle
The plan/apply cycle is Terraform's signature workflow. You run terraform plan to preview what will happen, review the output, then run terraform apply to execute.
Before you run this, what do you think Terraform will show you? How many resources will it plan to create?
- Preview what Terraform will create by running this command in your terminal:
terraform plan
Look at the output. Each + symbol marks an attribute that Terraform will create. At the bottom, you'll see "Plan: 1 to add" confirming one new resource.
What do the plan symbols mean?
Terraform uses three symbols in its plan output to tell you exactly what will happen.
- + means Terraform will create a new resource.
- ~ means Terraform will update an existing resource in place.
- - means Terraform will destroy a resource.
This preview is the safety net that sets Terraform apart. You see every change before anything is touched.
The plan looks good. Time to deploy for real.
- Create the S3 bucket by running:
terraform apply
- Terraform shows the same plan output and asks you to confirm. Type yes and press Enter.
Terraform creates your S3 bucket and writes the result to a local file called terraform.tfstate. You'll see "Apply complete! Resources: 1 added" when it finishes.
Seeing an error after terraform apply?
- If you see a credentials error, your AWS CLI may not be configured. Run aws sts get-caller-identity to check. If that fails, re-run aws configure and enter your access key, secret key, and region.
- If you see a "BucketAlreadyExists" error, your bucket name is not unique. Change the suffix in main.tf to something more specific (add extra numbers or characters), save, and run terraform apply again.
- If you see a syntax error, check that every brace {} and quote "" in main.tf is properly paired.
Why is my terraform apply failing?
Verify the bucket and examine the state file
Before you run the next command, do you think your bucket will show up in the list? What name do you expect to see?
- Confirm the bucket exists in AWS by running:
aws s3 ls
You should see your bucket name (starting with terraform-web-app-assets-) in the list. That's a real AWS resource, created entirely from code.
Now take a look at how Terraform tracks what it created.
- In the VS Code Explorer sidebar, click terraform.tfstate to open it.
- Scroll through the file and notice how Terraform records every attribute of your bucket: its name, ARN, region, tags, and more.
What is the state file?
The terraform.tfstate file is Terraform's memory. It maps every resource in your configuration to the real resource in AWS.
When you run terraform plan, Terraform compares your config files against this state file to figure out what changed. Never edit the state file by hand.
Don't see your bucket in the aws s3 ls output?
- Make sure terraform apply completed without errors. Scroll up in your terminal and look for "Apply complete!" near the bottom.
- Confirm your AWS CLI region matches the region in providers.tf. Run aws configure get region to check.
My bucket doesn't appear in aws s3 ls after terraform apply. What should I check?
You've deployed your first AWS resource entirely from code and seen exactly how Terraform plans, applies, and tracks changes. Next up, you'll deploy a web server you can actually visit in your browser.
Deploy a Web Server You Can Visit in Your Browser
Your S3 bucket proves that Terraform can create real resources on AWS. But a bucket is invisible to anyone who opens a browser.
In this step, you will build a full networking stack and launch a live nginx web server on EC2. By the end, you will paste a public IP address into your browser and see a real web page.
In this step, get ready to:
- Create a VPC with a public subnet, internet gateway, and route table.
- Launch an EC2 instance running nginx with a security group that allows web traffic.
- Visit the live web page in your browser.
Create the VPC and networking resources
Before your server can serve web pages, it needs a network to live in. On AWS, that network is a VPC with a subnet, an internet gateway, and a route table that connects everything to the public internet.
- In main.tf, add the following AMI data source below your aws_s3_bucket resource:
# Look up the latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
What is a data source?
A data block does not create a resource. It queries AWS for information that already exists. This block finds the most recent Amazon Linux 2023 AMI (machine image) so you can reference its ID when launching your EC2 instance later.
- Below the AMI data source, add the VPC and public subnet:
# Create the VPC that holds all networking resources
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
Name = "terraform-web-app-vpc"
Project = "terraform-web-app"
ManagedBy = "terraform"
}
}
# Create a public subnet inside the VPC
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
map_public_ip_on_launch = true
tags = {
Name = "terraform-web-app-public"
Project = "terraform-web-app"
}
}
What are the VPC and subnet doing?
The VPC is your own isolated network on AWS. Think of it as a private office building. The CIDR block 10.0.0.0/16 gives it roughly 65,000 internal IP addresses.
The subnet is one floor of that building. Setting map_public_ip_on_launch = true means every server placed in this subnet automatically gets a public IP address that the outside world can reach.
- Save main.tf.
- Preview the new resources by running:
terraform plan
You should see several lines prefixed with + (meaning Terraform will create them) and a summary ending with resources to add.
Seeing a syntax error?
- Check that every opening brace { has a matching closing brace } in your new resource blocks.
- Make sure the AMI data source, VPC, and subnet blocks are placed below the closing } of the aws_s3_bucket resource, not inside it.
- Confirm there are no stray characters or missing quotes in the tag values.
Help me fix a Terraform plan error in my VPC and subnet configuration.
- Below the subnet, add the internet gateway, route table, and route table association:
# Attach an internet gateway so traffic can reach the public internet
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "terraform-web-app-igw"
Project = "terraform-web-app"
}
}
# Route all outbound traffic through 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 = "terraform-web-app-public-rt"
Project = "terraform-web-app"
}
}
# Link the route table to the public subnet
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
Why do you need all three resources?
The internet gateway is the door between your VPC and the public internet. The route table contains the rule "send all traffic headed for 0.0.0.0/0 (anywhere) through that door." The association connects that rule to your specific subnet.
Without all three, your server would have a public IP but no way to actually send or receive traffic.
- Save main.tf.
- Run terraform plan again to confirm Terraform now sees all five networking resources plus the AMI data source read.
Add the security group and web server instance
The network is ready, but nothing can connect to it yet. You need a security group to control which traffic is allowed in, and an EC2 instance to actually run nginx.
- Below the route table association in main.tf, add the security group:
# Allow inbound HTTP traffic and all outbound traffic
resource "aws_security_group" "web" {
name = "terraform-web-app-web-sg"
description = "Allow HTTP inbound traffic"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "terraform-web-app-web-sg"
Project = "terraform-web-app"
}
}
What does this security group do?
The ingress block opens port 80 (HTTP) to the entire internet so anyone can view your web page. The egress block allows all outbound traffic so the server can download packages like nginx during startup.
Protocol -1 in the egress block means "all protocols."
- Below the security group, add the EC2 instance that runs your web server:
# Launch an EC2 instance that installs and starts nginx on boot
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]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y nginx
systemctl enable nginx
systemctl start nginx
EOF
tags = {
Name = "terraform-web-app-web"
Project = "terraform-web-app"
ManagedBy = "terraform"
}
}
What is user_data?
The user_data block is a shell script that runs automatically when the EC2 instance first boots. This one updates the system packages, installs nginx, and starts the web server.
The instance type t2.micro is eligible for the AWS free tier. The vpc_security_group_ids attribute attaches your security group so the instance inherits those firewall rules.
- Save main.tf.
- Preview all your new resources by running:
terraform plan
You should see around 7-8 resources to add (VPC, subnet, internet gateway, route table, route table association, security group, and EC2 instance, plus the existing S3 bucket).
Plan shows errors about missing references?
- Verify that the resource names in your references match exactly. For example, aws_vpc.main.id requires a resource block named "aws_vpc" "main".
- Make sure the data "aws_ami" "amazon_linux" block is present. The EC2 instance references it with data.aws_ami.amazon_linux.id.
- Check that no resource blocks overlap or are nested inside each other.
Help me fix reference errors in my Terraform plan.
- Create a new file called outputs.tf in your terraform-web-app folder.
- Add the following output so Terraform prints the server's public IP after deploying:
# Print the public IP so you can visit the server in a browser
output "web_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
What does an output do?
An output block tells Terraform to print a specific value after terraform apply finishes. Without this, you would have to dig through the AWS Console to find your server's IP.
- Save outputs.tf.
\u2714\uFE0F Awesome, I've got everything!
Great. Double check you have saved both main.tf and outputs.tf.
\u24E7 I'd like to double check the full code
Your main.tf is now over 30 lines. Scroll through the file and confirm these blocks appear in order:
- aws_s3_bucket "app_assets" (already existed).
- data "aws_ami" "amazon_linux" (AMI lookup).
- aws_vpc "main" (VPC with CIDR 10.0.0.0/16).
- aws_subnet "public" (subnet with CIDR 10.0.1.0/24).
- aws_internet_gateway "main" (attached to VPC).
- aws_route_table "public" (route 0.0.0.0/0 through IGW).
- aws_route_table_association "public" (links route table to subnet).
- aws_security_group "web" (port 80 inbound, all outbound).
- aws_instance "web" (t2.micro, nginx user_data).
Your outputs.tf should contain:
output "web_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
Deploy and visit your web server
Everything is defined. Time to deploy all of these resources to AWS and see your web server running in a real browser.
Before you run the next command, think about this: do you expect Terraform to create every resource from scratch, or will it skip the S3 bucket that already exists?
- Deploy all the resources by running:
terraform apply
- Terraform shows the full plan, then asks for confirmation. Type yes and press Enter.
What should I see?
Terraform creates each resource in dependency order. It starts with the VPC, then the subnet, then the internet gateway and route table, then the security group, and finally the EC2 instance. The S3 bucket already exists, so Terraform skips it.
The entire process takes about 1-2 minutes. At the end, you should see a line like web_public_ip = "<some IP address>" printed in the terminal output.
- Copy the IP address from the web_public_ip output.
- Wait about 1-2 minutes for the EC2 instance to finish booting and installing nginx.
- Open your browser and paste http:// followed by the IP address into the address bar.
You should see the nginx default welcome page with a heading that says "Welcome to nginx!" This confirms your server is running and publicly accessible.
Page not loading in the browser?
- Make sure you are using http:// and not https://. Your security group only allows HTTP on port 80.
- Wait a full 2 minutes after terraform apply completes. The EC2 instance needs time to run yum update and install nginx before the page is available.
- Verify that your security group's ingress block uses from_port = 80 and cidr_blocks = ["0.0.0.0/0"]. A missing CIDR block means no traffic is allowed in.
- Check that your subnet has map_public_ip_on_launch = true. Without this, the instance has no public IP even though Terraform outputs one.
Help me debug why my Terraform-deployed nginx server is not loading in the browser.
You just deployed a complete web server stack from code. In the next step, you will modify the infrastructure and see how Terraform detects exactly what changed.
Modify Infrastructure and See Terraform Detect Changes
Your web server is live and serving the default nginx page. That proves Terraform can create infrastructure from code.
Creating resources is only half the story. The real power of Terraform is managing changes to existing infrastructure. When you edit your config and run terraform plan, Terraform shows you exactly what will change before it touches anything. In this step, you will see two kinds of changes and learn why the difference matters.
In this step, get ready to:
- Add a tag to your EC2 instance and see Terraform apply it as a safe, in-place update.
- Change the instance's startup script and see Terraform flag it as a disruptive change that requires replacement.
- Visit your updated web page at a new public IP to confirm the replacement worked.
Add an environment tag to your EC2 instance
Tags are metadata labels on AWS resources. Adding or changing a tag does not affect the running server at all. Terraform can update tags without stopping or replacing the instance.
- In VS Code, open main.tf.
- Scroll down to the aws_instance "web" resource and find the tags block. It currently looks like this:
tags = {
Name = "terraform-web-app-web"
Project = "terraform-web-app"
ManagedBy = "terraform"
}
- Add a new line Environment = "dev" inside the tags block, so it now looks like this:
tags = {
Name = "terraform-web-app-web"
Project = "terraform-web-app"
Environment = "dev"
ManagedBy = "terraform"
}
- Save main.tf.
Before you run the plan, think about what you expect Terraform to do. Will it destroy the server and build a new one, or update it in place?
- Preview the change by running this command in your terminal:
terraform plan
Terraform shows a ~ symbol next to the instance. That ~ means update in-place. The server keeps running at the same IP address while Terraform adds the tag.
What do the plan symbols mean?
Terraform uses three key symbols in its plan output. + means create a new resource. ~ means update an existing resource in place. - means destroy a resource.
For this tag change, you see ~ because tags are metadata that AWS can change on a running instance without any disruption.
- Apply the tag change by running:
terraform apply
- Type yes when Terraform asks you to confirm.
- After the apply completes, refresh your browser tab that has the nginx page open. The same page loads at the same IP address. The server was never interrupted.
Page not loading after apply?
The tag change should not affect your server at all. If the page stopped loading, check that you only modified the tags block and did not accidentally edit other attributes like user_data or subnet_id.
Help me figure out why my web page stopped loading after a tag change.
Update the startup script to serve a custom page
Tags are safe to change because they are just labels. Now try something more fundamental: changing the startup script that runs when the instance first boots.
- In main.tf, find the user_data block inside your aws_instance "web" resource. It currently looks like this:
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y nginx
systemctl enable nginx
systemctl start nginx
EOF
- Replace the entire user_data block with the following version that writes a custom HTML page after starting nginx:
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y nginx
systemctl enable nginx
systemctl start nginx
cat > /usr/share/nginx/html/index.html <<'HTML'
<!DOCTYPE html>
<html>
<head><title>Terraform Web App</title></head>
<body>
<h1>Hello from Terraform!</h1>
<p>Environment: dev</p>
<p>This server was provisioned entirely with infrastructure as code.</p>
</body>
</html>
HTML
EOF
What does this user_data script do?
The script still installs and starts nginx, just like before. The new part uses cat > to overwrite the default nginx welcome page with a custom HTML file.
When the instance boots for the first time, user_data runs automatically. That is the key detail: user_data only executes on the very first boot of an instance.
- Save main.tf.
Before you run the plan, predict what Terraform will do. Will this be another safe ~ in-place update like the tag change?
- Preview the change by running:
terraform plan
Terraform shows ~ again, indicating an in-place update. It plans to stop the instance, update the user_data attribute, and start it back up.
Stop/start will not apply your new page
This is a trap. The user_data script only runs on the very first boot of an EC2 instance. A stop/start is not a first boot. If you run terraform apply right now, Terraform will stop and restart your server, but the custom HTML page will never appear. You would still see the old default nginx welcome page.
This is a critical lesson for production infrastructure. Some changes look safe in the plan but do not actually take effect without a full replacement. In a real environment, this kind of silent failure can leave your servers running stale configurations.
To actually apply the new startup script, you need to force Terraform to destroy the old instance and create a brand new one. Terraform has a -replace flag for exactly this situation.
Force replacement and verify your custom page
- Apply the changes with the -replace flag to force a full instance replacement by running:
terraform apply -replace=aws_instance.web
What does -replace do?
The -replace flag tells Terraform to destroy and recreate a specific resource, even if the plan would normally do a less disruptive update. You will see -/+ in the plan output, meaning "destroy then create."
This is the right approach when you know a change (like user_data) only takes effect on a fresh instance. The new instance boots, runs the updated user_data script for the first time, and your custom page gets written.
- Type yes when Terraform asks you to confirm.
- Wait about 2 minutes for the new instance to boot and for nginx to install and start.
Notice that the web_public_ip output value has changed. The old instance was destroyed and a brand new one was created with a different IP address.
- Copy the new web_public_ip value from the Terraform output.
- Open a new browser tab and paste this URL, replacing the IP with your output value:
http://[[WEB_PUBLIC_IP="your new public IP from Terraform output"]]
You should see your custom page displaying "Hello from Terraform!" along with "Environment: dev" underneath it.
Still seeing the nginx welcome page?
The instance may still be booting. Wait another minute and refresh. The user_data script installs nginx and writes the custom page, which takes time on a fresh instance.
If the page still does not appear after 2-3 minutes, confirm you used -replace=aws_instance.web in your apply command. A regular terraform apply without -replace only stops and restarts the instance, which does not re-run the startup script.
My custom page is not showing up after terraform apply -replace. Help me debug.
Why does this matter in production?
In this step you saw two fundamentally different kinds of infrastructure changes. Tag changes are non-disruptive. Terraform updates them in place with zero downtime. Startup script changes are disruptive. They require destroying the server and building a new one from scratch.
In a production environment, the difference between these two types is the difference between a seamless update and an outage. This is exactly why terraform plan exists. You always preview the blast radius of a change before applying it.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf with both the Environment = "dev" tag and the updated user_data script.
ⓧ I'd like to double check the full code
Your main.tf is long, so scroll through and confirm each section is present in order:
- The aws_s3_bucket "app_assets" resource at the top.
- The data "aws_ami" "amazon_linux" block with filters for al2023-ami-*-x86_64.
- The aws_vpc, aws_subnet, aws_internet_gateway, aws_route_table, and aws_route_table_association resources.
- The aws_security_group "web" resource allowing port 80 inbound.
- The aws_instance "web" resource with the updated user_data that writes the custom HTML page, and tags including Environment = "dev".
Your outputs.tf and providers.tf files should be unchanged from the previous step.
You have seen Terraform's change detection in action. Some edits slide through without disruption, while others require tearing down and rebuilding. Next up, you will take all of this flat configuration and refactor it into a reusable module that any team can deploy.
Refactor into a Reusable Module
In the previous step, you saw firsthand how Terraform detects changes. Some edits trigger safe in-place updates, while others force a full replacement.
Right now, all your infrastructure code lives in a single flat main.tf with hardcoded values. If another team wanted to deploy the same setup, they would need to copy your files and manually change every value. Terraform modules solve this the same way functions solve code duplication in programming. You define the infrastructure pattern once, then call it with different inputs.
In this step, get ready to:
- Create a reusable module directory following HashiCorp's standard module structure.
- Replace hardcoded values with variables so the module accepts different inputs.
- Confirm the refactored module deploys identical infrastructure to your flat code.
Create the module directory and define its interface
HashiCorp's standard module structure uses three files: main.tf for resources, variables.tf for inputs, and outputs.tf for values other configurations can reference. You will create all three inside a modules/web-server/ directory.
- Create the module directory by running this command in your terminal:
mkdir -p modules/web-server
- Verify the directory was created by running:
ls modules/
You should see web-server listed in the output.
- In VS Code, create a new file called variables.tf inside modules/web-server/ and paste the following code:
variable "project_name" {
description = "Name of the project, used for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment (e.g., dev, staging, prod)"
type = string
default = "dev"
}
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"
}
What do these variables do?
- project_name is the only required variable (no default). Every caller must provide a project name for tagging.
- environment, vpc_cidr, subnet_cidr, and instance_type all have sensible defaults. A caller can override any of them to customize the deployment.
- Create another new file called outputs.tf inside modules/web-server/ and paste the following code:
output "public_ip" {
description = "Public IP address of the web server"
value = aws_instance.web.public_ip
}
output "instance_id" {
description = "EC2 instance ID"
value = aws_instance.web.id
}
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
What do module outputs do?
Outputs are the module's return values. Without them, the root configuration has no way to access the resources the module creates. Here, you expose the web server's public_ip (so you can visit it), the instance_id (for monitoring), and the vpc_id (for reference).
- Save both files.
- Verify the module directory structure by running:
ls modules/web-server/
You should see outputs.tf and variables.tf listed.
Don't see the files?
- Make sure you created the files inside modules/web-server/, not in the project root or in modules/ directly.
- Check that your terminal is still in the terraform-web-app directory.
- help me find where my module files ended up
Build the module's infrastructure code
Now you will create modules/web-server/main.tf with all the infrastructure resources. The code is similar to what you already have in your root main.tf, but every hardcoded value is replaced with a var.* reference. You will build this file in sections.
- Create a new file called main.tf inside modules/web-server/ and paste the following code for the AMI data source and VPC:
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
What changed from the flat code?
- The VPC's cidr_block now reads from var.vpc_cidr instead of the hardcoded "10.0.0.0/16".
- Tags use string interpolation like ${var.project_name}-${var.environment}-vpc so each deployment gets uniquely named resources.
- An Environment tag is added to every resource, making it easy to filter by environment in the AWS console.
- Add the subnet and internet gateway below the VPC code in the same file:
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.subnet_cidr
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public"
Project = var.project_name
Environment = var.environment
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
Project = var.project_name
Environment = var.environment
}
}
What does this code do?
The subnet's cidr_block now uses var.subnet_cidr. This is critical for deploying multiple environments side by side, because two VPCs cannot share the same CIDR block.
- 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.project_name}-${var.environment}-public-rt"
Project = var.project_name
Environment = var.environment
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
- Save modules/web-server/main.tf.
- Confirm the file's syntax is valid by running:
terraform fmt modules/web-server/
If the file has valid HCL syntax, the command prints the filename. If you see an error, check for missing braces or quotes in the code you just pasted.
- Add the security group below the route table association in modules/web-server/main.tf:
resource "aws_security_group" "web" {
name = "${var.project_name}-${var.environment}-web-sg"
description = "Allow HTTP inbound traffic"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-web-sg"
Project = var.project_name
Environment = var.environment
}
}
What does this code do?
The security group's name attribute now uses interpolation. In the flat code, the name was hardcoded to terraform-web-app-web-sg. The module version generates a unique name per environment, like terraform-web-app-dev-web-sg or terraform-web-app-staging-web-sg.
- 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 nginx
systemctl enable nginx
systemctl start nginx
cat > /usr/share/nginx/html/index.html <<'HTML'
<!DOCTYPE html>
<html>
<head><title>Terraform Web App</title></head>
<body>
<h1>Hello from Terraform!</h1>
<p>Environment: ${var.environment}</p>
<p>Project: ${var.project_name}</p>
<p>This server was provisioned entirely with infrastructure as code.</p>
</body>
</html>
HTML
EOF
tags = {
Name = "${var.project_name}-${var.environment}-web"
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
How does the user_data use variables?
Terraform evaluates ${var.environment} and ${var.project_name} inside the heredoc before sending the script to EC2. By the time the shell script runs on the instance, those placeholders are already replaced with real values like dev and terraform-web-app.
This also means the HTML page now shows Project: terraform-web-app in addition to the environment name. A small visual addition that proves the module variables are working.
- Save modules/web-server/main.tf.
- Run terraform fmt again to confirm the full file is syntactically valid:
terraform fmt modules/web-server/
If you see the filename printed with no errors, the file is ready.
Seeing a syntax error?
- Check for missing closing braces } between each resource block.
- Make sure the heredoc delimiters <<-EOF and EOF are on their own lines with no trailing spaces.
- Verify that string interpolations use the format ${var.name} with curly braces, not parentheses.
- help me fix the syntax error in my Terraform module
✔️ Awesome, I've got everything!
Great. Make sure you have saved modules/web-server/main.tf.
ⓧ I'd like to double check the full code
Your modules/web-server/main.tf should contain all five sections in this order. Scroll up and confirm each one is present:
- The aws_ami data source and aws_vpc resource (first code block you pasted).
- The aws_subnet and aws_internet_gateway resources.
- The aws_route_table and aws_route_table_association resources.
- The aws_security_group resource.
- The aws_instance resource with the user_data heredoc at the bottom.
Every tag should use var.project_name and var.environment interpolation, not hardcoded strings.
Update the root configuration and apply
The module files are ready. Now you need to update your root main.tf to remove the flat resource blocks and call the module instead. You also need to update outputs.tf to reference the module's outputs.
- Open your root main.tf (the one in the terraform-web-app directory, not inside modules/) and replace its entire contents with the following code:
resource "aws_s3_bucket" "app_assets" {
bucket = "terraform-web-app-assets-CHANGE-ME"
tags = {
Project = "terraform-web-app"
ManagedBy = "terraform"
}
}
module "web" {
source = "./modules/web-server"
project_name = "terraform-web-app"
environment = "dev"
}
What changed here?
- The aws_s3_bucket stays in the root because it is not part of the web server pattern. Not every resource belongs in every module.
- The module "web" block replaces all the VPC, subnet, gateway, security group, and EC2 resource blocks. The source points to your local module directory.
- You only pass project_name and environment because the other variables (vpc_cidr, subnet_cidr, instance_type) use their defaults.
- Open your root outputs.tf and replace its entire contents with the following code:
output "web_public_ip" {
description = "Public IP of the dev web server"
value = module.web.public_ip
}
output "web_instance_id" {
description = "Instance ID of the dev web server"
value = module.web.instance_id
}
output "web_vpc_id" {
description = "VPC ID of the dev environment"
value = module.web.vpc_id
}
output "s3_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.app_assets.id
}
What does this code do?
Every output now references module.web.* instead of the resource directly. For example, module.web.public_ip reads from the public_ip output you defined in modules/web-server/outputs.tf. You also added outputs for instance_id, vpc_id, and the S3 bucket name.
- Save both main.tf and outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved both root files.
ⓧ I'd like to double check the full code
Your root main.tf should contain only the aws_s3_bucket resource and the module "web" block. No VPC, subnet, gateway, security group, or EC2 resource blocks should remain.
Your root outputs.tf should have four outputs: web_public_ip, web_instance_id, web_vpc_id, and s3_bucket_name. All except s3_bucket_name should reference module.web.*.
- Initialize Terraform to register the new module source by running:
terraform init
Terraform detects the local module and outputs a success message. You need to run terraform init whenever you add a new source to a module block.
Seeing a module not found error?
- Verify the source path in your module block is ./modules/web-server with the leading ./. Without it, Terraform looks in the registry instead of your local directory.
- Confirm the directory exists by running ls modules/web-server/. You should see main.tf, variables.tf, and outputs.tf.
- help me fix my Terraform module source path
Before you run terraform plan, think about what you expect to see. Your infrastructure is identical, but every resource now lives at a new Terraform address (like module.web.aws_vpc.main instead of aws_vpc.main). Will Terraform treat this as "no changes" or as something else?
- Run the plan to see what Terraform detects:
terraform plan
Terraform shows resources being destroyed at their old root addresses and recreated at new module addresses. This is expected. Terraform identifies resources by their full state address, so moving code into a module looks like a delete-plus-create to the state file.
Why does Terraform destroy and recreate?
Terraform tracks every resource by its address in the state file. Your VPC was aws_vpc.main and is now module.web.aws_vpc.main. Terraform sees the old address as deleted and the new address as brand new.
In a production environment, you would use moved blocks to tell Terraform the resource relocated without needing to recreate it. For this learning project, the destroy-and-recreate is safe and gets you the same result.
- Apply the changes by running:
terraform apply
- Type yes when prompted to confirm.
Terraform destroys the old resources and creates new ones under the module addresses. This takes about 2 minutes while the new EC2 instance boots and installs nginx.
When the apply finishes, copy the web_public_ip value from the outputs. Paste http:// followed by the IP into your browser.
You should see your "Hello from Terraform!" page with Environment: dev and a new line showing Project: terraform-web-app. That new line proves the module's variables are working.
Page not loading after apply?
- Wait 1-2 minutes after terraform apply completes. The EC2 instance needs time to boot, install nginx, and start the web server.
- Make sure you are using http:// (not https://) in the browser URL. The security group only allows HTTP on port 80.
- Verify the IP address in the web_public_ip output matches what you pasted. Run terraform output web_public_ip to double check.
- help me debug why my Terraform web server page is not loading
Your infrastructure is now fully modularized. The same VPC, subnet, security group, and EC2 instance are running, but the code that defines them is reusable. Next up, you will put that reusability to the test by deploying a second environment from the exact same module.
Secret mission
Deploy a Staging Environment from the Same Module
Your module works for one environment. But corporate teams don't run just one. Can you deploy a second, completely separate staging environment from the exact same module, without duplicating a single resource block?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project created AWS resources that may incur charges if left running beyond your free tier allowance.
Cost warning
Your two EC2 instances are consuming free tier hours while running. Two t2.micro instances running around the clock use roughly 1,440 hours per month. This exceeds the 750-hour monthly free tier limit.
Free tier eligibility depends on your account creation date. Run terraform destroy as soon as you are finished to avoid unexpected charges.
Resources you used:
- 2 EC2 instances (dev and staging, t2.micro, free tier eligible for 750 hrs/month).
- 2 VPCs, each with a public subnet, internet gateway, route table, and security group (no cost).
- 1 S3 bucket (free tier covers 5 GB storage).
- Local terraform-web-app project directory on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your Terraform project.
- Your EC2 instances will continue consuming free tier hours. Monitor usage in the AWS Billing console to avoid surprise charges.
- The S3 bucket remains in your account. Storage is minimal for this project.
- Your local terraform-web-app directory stays on your Desktop.
Pause - I'll come back to this later
Stop your EC2 instances to avoid compute charges while keeping your Terraform state and project files intact.
- Open the EC2 console at https://console.aws.amazon.com/ec2/.
- Click Instances in the left sidebar.
- Select the first instance (tagged terraform-web-app-dev-web).
- Click Instance state, then click Stop instance.
- Repeat for the second instance (tagged terraform-web-app-staging-web).
- Minimal storage charges still apply for the stopped instances, but compute charges stop immediately.
- Your local project files and terraform.tfstate remain intact. When you return, run terraform apply to restart everything.
Delete - I don't want to use this again
Remove all AWS resources and local project files. This is the recommended option.
- In your VS Code terminal (inside the terraform-web-app directory), run the following command:
terraform destroy
- Type yes when Terraform asks for confirmation. Terraform removes every resource it created in the correct dependency order.
- Verify that no EC2 instances remain by running this command:
aws ec2 describe-instances --query "Reservations[].Instances[?Tags[?Value=='terraform-web-app']].InstanceId" --output text
- You should see empty output, confirming all tagged instances are gone.
- Delete your local project files. Navigate out of the project directory and remove it.
macOS
cd ~/Desktop
rm -rf terraform-web-app
Windows
cd ~\Desktop
rmdir /s /q terraform-web-app
- Confirm the folder is gone by running ls on your Desktop. You should no longer see terraform-web-app in the output.
Nice Work!
Nice Work!
You just deployed a live web server on AWS, managed entirely through Terraform configuration files you wrote from scratch. You can tear it all down and rebuild it in minutes.
You've learned how to:
- Deploy real AWS infrastructure (a VPC, EC2 instance, and S3 bucket) entirely from Terraform configuration files, using the full terraform init, terraform plan, terraform apply, and terraform destroy lifecycle.
- Experience how Terraform detects infrastructure changes, distinguishing between in-place updates (like adding a tag) and destroy-and-recreate operations (like modifying user_data). This plan-before-apply safety net is what sets Terraform apart from imperative tools.
- Refactor flat configuration into a reusable module following HashiCorp's standard structure (main.tf, variables.tf, outputs.tf). This is the same pattern corporate teams use to manage infrastructure across environments.
- Completed an optional Secret Mission to push your module skills even further.
Ready to quiz yourself?