Deploy a Monitored Web Server with Terraform

Deploy a live web server on AWS EC2 inside a custom VPC using Terraform.

Introduction

30 Second Summary

Every time you visit a website, a server somewhere is running, connected to a network, and hopefully being monitored. When that server goes down, the engineer who deployed it needs to understand exactly how everything fits together to fix it fast.

In this project, you will deploy a live web server on AWS using Terraform, hit a real networking problem, diagnose and fix it, then add monitoring. You will write infrastructure as code that creates and destroys your entire cloud setup with a single command.

What You'll Build

You will type one command, watch Terraform spin up a web server inside a custom VPC on AWS, then visit the live page in your browser and share the URL with anyone.

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

  • A live web page served from your own EC2 instance that anyone can visit by opening the public URL in their browser.
  • Terraform configuration files that create and destroy your entire AWS infrastructure with a single command.
  • A custom VPC with public subnet, internet gateway, and route table, plus a CloudWatch alarm that fires when your server's CPU spikes. These are the exact networking and monitoring concepts interviewers test.
  • Secret Mission: Wire up SNS email notifications and stress-test your server to trigger the full alerting pipeline end-to-end.

Are there any prerequisites?

You will need an AWS account with free tier active, a GitHub account, and Homebrew installed on macOS. Step 1 walks you through installing Terraform and the AWS CLI.

Before We Start

Before you can deploy anything to AWS, you need two tools on your machine. Terraform translates your infrastructure code into real cloud resources. The AWS CLI lets Terraform authenticate with your AWS account.

In this step, you will install both tools, connect them to your AWS account, and set up a project folder for your Terraform files.

In this step, get ready to:
  • Install and verify Terraform.
  • Install and configure the AWS CLI.
  • Create the project directory with Git.
Install Terraform via Homebrew

Terraform is the Infrastructure as Code tool you will use to define and deploy all your AWS resources. Homebrew makes installation straightforward on macOS.

  • Add the HashiCorp tap and install Terraform by running these commands:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform

What do these commands do?

The first command adds HashiCorp's package repository (called a "tap") to Homebrew so it knows where to find Terraform. The second command downloads and installs the latest version of Terraform from that repository.

  • Verify the installation by running:
terraform --version

Check the output in your terminal:

✔️ I see version 1.2 or higher

You should see something like Terraform v1.15.8. Terraform is installed and ready to go.

ⓧ I see an older version

This project requires Terraform 1.2 or higher. Upgrade by running:

brew upgrade hashicorp/tap/terraform

Then run terraform --version again to confirm the new version.

ⓧ Command not found

The install may not have completed successfully. Try running the tap and install commands again:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

If Homebrew itself is not recognized, confirm it is installed by running brew --version.

Install and configure the AWS CLI

The AWS CLI lets you interact with AWS services from your terminal. Terraform uses the credentials you configure here to authenticate with your account.

  • Download the official AWS CLI v2 installer by running:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
  • Run the installer by entering:
sudo installer -pkg AWSCLIV2.pkg -target /

What happens when you run this?

Your terminal will prompt you for your macOS password because sudo requires administrator privileges. Type your password (it will not appear on screen) and press Enter.

  • Verify the installation by running:
aws --version

Check the output in your terminal:

✔️ I see aws-cli/2

You should see output starting with aws-cli/2 followed by a version number. The AWS CLI is installed and ready.

ⓧ I see aws-cli/1

You have an older version installed. This project requires AWS CLI v2. Remove the old version and reinstall by running the curl and sudo installer commands above again.

ⓧ Command not found

The installer may not have completed successfully. Try running both the download and install commands again. Make sure you enter your macOS password when prompted.

Now you need to connect the CLI to your AWS account. You will need an Access Key ID and Secret Access Key from IAM (Identity and Access Management) in the AWS Console.

Where do I get my access keys?

Sign in to the AWS Console. Click your account name in the upper-right corner, then choose Security credentials.

Scroll to the Access keys section and click Create access key. Select Command Line Interface (CLI) as the use case, acknowledge the recommendation, then click Next and Create access key.

Copy both the Access Key ID and Secret Access Key. This is the only time you can view the secret key, so save it somewhere safe before closing.

  • Configure your credentials by running:
aws configure

What will this command ask?

You will be prompted for four values:

  • AWS Access Key ID - paste the key you just created.
  • AWS Secret Access Key - paste the secret key you just created.
  • Default region name - enter us-east-1.
  • Default output format - enter json.

Before you run the next command, what do you think it will show if your credentials are configured correctly?

  • Verify your credentials are working by running:
aws sts get-caller-identity

You should see JSON output containing your AWS Account ID, User ID, and ARN. This confirms Terraform will be able to authenticate with your account.

Seeing "Unable to locate credentials"?

  • Run aws configure again and double-check you pasted the Access Key ID and Secret Access Key correctly.
  • Make sure you did not add extra spaces before or after the key values.

Help me fix my AWS CLI credentials

Create the project directory and initialize Git

Your Terraform files need a dedicated folder. Initializing it as a Git repository from the start means every change you make is tracked and ready to push to GitHub later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir terraform-aws-webserver
cd terraform-aws-webserver
git init
  • Confirm the Git repository was created by running:
ls -la

You should see a .git directory in the output. This means Git is tracking your project folder.

  • Open this folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the terraform-aws-webserver folder on your Desktop, then click Open.

Don't see the .git folder?

  • Files starting with a dot are hidden by default. The ls -la command shows hidden files. If you see .git in that output, you are good to go.
  • If .git is missing, make sure you ran git init inside the terraform-aws-webserver folder, not one level above it.

Help me set up my project directory

Your environment is ready. Next up, you will write your first Terraform configuration and deploy an EC2 instance to AWS.

Deploy Your First EC2 Instance

Your development environment is ready with Terraform and the AWS CLI configured. Now it is time to write real infrastructure code and see it create a live server in the cloud.

You need a running server before you can serve web traffic to anyone. In this step, you will write Terraform configuration files that define an EC2 instance running Apache, deploy it with a single command, and watch Terraform provision your server on AWS.

In this step, get ready to:
  • Write Terraform configuration files that define the AWS provider, variables, your server, and its outputs.
  • Create a startup script that installs Apache and serves a custom web page.
  • Run terraform init and terraform apply to deploy your instance to AWS.
Write the Terraform configuration files

Terraform reads .tf files to understand what infrastructure to create. You will split your configuration across four files, each with a clear responsibility.

  • In VS Code, create a new file called terraform.tf in your project folder and add this code:
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.92"
    }
  }

  required_version = ">= 1.2"
}

What does this code do?

This block tells Terraform which provider plugin to download. The hashicorp/aws provider is the bridge between Terraform and the AWS API.

The ~> 5.92 constraint means "version 5.92 or higher, but not 6.0". The required_version line ensures anyone running this code has Terraform 1.2 or newer.

  • Create a second file called variables.tf and add this code:
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

What does this code do?

Variables let you change values without editing your main configuration. Here you define two: the AWS region (defaulting to us-east-1) and the instance type (defaulting to t3.micro, which is free tier eligible).

  • Save both files.
  • Confirm the files exist by running this in your terminal:
ls

You should see terraform.tf and variables.tf listed in the output.

  • Create a third file called main.tf and add the provider block and AMI data source:
provider "aws" {
  region = var.aws_region
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

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

What does this code do?

The provider block connects Terraform to AWS in your chosen region. It reads the aws_region variable you just defined.

The data block is a lookup query. It finds the most recent Amazon Linux 2023 AMI (machine image) that uses x86_64 architecture and HVM virtualization. Instead of hardcoding an AMI ID that goes stale, this always grabs the latest.

  • Below the data source you just added, add the EC2 instance resource:
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type
  user_data     = file("userdata.sh")

  tags = {
    Name = "portfolio-web-server"
  }
}

What does this code do?

This is the actual server definition. Terraform will create an EC2 instance using the AMI found by the data source and the instance type from your variable.

The user_data argument points to a shell script (you will create it next) that runs automatically when the instance first boots. The Name tag makes it easy to find your server in the AWS console.

  • Save main.tf.
  • Confirm it appears alongside your other files by running:
ls

You should now see terraform.tf, variables.tf, and main.tf listed.

  • Create a fourth file called outputs.tf and add this code:
output "public_ip" {
  description = "Public IP of the web server"
  value       = aws_instance.web.public_ip
}

output "public_url" {
  description = "URL to access the web server"
  value       = "http://${aws_instance.web.public_ip}"
}

What does this code do?

Outputs print useful values after Terraform finishes creating resources. Here you will see the server's public IP address and a ready-to-click URL. Without outputs, you would have to dig through the AWS console to find your server's address.

  • Save outputs.tf.

Seeing red squiggles in VS Code?

If VS Code shows syntax highlighting errors in your .tf files, it likely does not have an HCL extension installed. You can ignore the squiggles safely. Terraform itself validates syntax when you run terraform init.

Help me fix my VS Code syntax highlighting.

Create the user data script

Your EC2 instance needs instructions for what to do when it first boots up. This is called a user data script.

  • Create a file called userdata.sh and add this code:
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from my Portfolio Web Server!</h1><p>Deployed with Terraform on AWS</p>" > /var/www/html/index.html

What does this script do?

When the instance boots, it updates packages, installs the Apache web server (httpd), starts it, and enables it to run on every reboot.

The last line writes a custom HTML page to Apache's default web directory. This is the page you will eventually see in your browser.

  • Save userdata.sh.
  • Confirm all five project files exist by running:
ls

You should see main.tf, outputs.tf, terraform.tf, userdata.sh, and variables.tf listed.

✔️ Awesome, I've got everything!

Great. Double check you have saved all five files before continuing.

ⓧ I'd like to double check the full code

Here is each file in full. Compare yours against these:

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

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

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}
provider "aws" {
  region = var.aws_region
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

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

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type
  user_data     = file("userdata.sh")

  tags = {
    Name = "portfolio-web-server"
  }
}
output "public_ip" {
  description = "Public IP of the web server"
  value       = aws_instance.web.public_ip
}

output "public_url" {
  description = "URL to access the web server"
  value       = "http://${aws_instance.web.public_ip}"
}
#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from my Portfolio Web Server!</h1><p>Deployed with Terraform on AWS</p>" > /var/www/html/index.html
Initialize and deploy with Terraform

Your configuration is ready. Now Terraform needs to download the AWS provider plugin before it can talk to AWS.

  • Initialize your Terraform workspace by running:
terraform init

What does this command do?

The terraform init command reads your terraform.tf file, downloads the hashicorp/aws provider plugin, and creates a .terraform.lock.hcl file that pins the exact provider version. You only need to run this once per project (or after changing providers).

You should see a message ending with Terraform has been successfully initialized! along with a suggestion to run terraform plan.

Seeing a provider error?

If you see an error about failing to query available provider packages, check that your internet connection is active. Terraform needs to download the AWS provider from the HashiCorp registry.

If you see a version constraint error, double check that terraform.tf has version = "~> 5.92" with the tilde and angle bracket.

Help me troubleshoot my Terraform init error.

Now it is time to deploy. Before you run the next command, think about what you expect to happen. Terraform will show you a plan of what it intends to create and ask for confirmation.

  • Deploy your EC2 instance by running:
terraform apply
  • Terraform displays an execution plan showing the resources it will create. Type yes when prompted to confirm.

What should I see?

Terraform shows aws_instance.web: Creating... followed by progress messages while AWS provisions your server. After about 30 seconds, you will see:

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

Below that, Terraform prints your outputs: a public_ip and a public_url you can copy.

Getting an authentication error?

If Terraform says it cannot find credentials, run aws sts get-caller-identity to confirm your AWS CLI is still configured. If that command fails, re-run aws configure and re-enter your access key and secret key.

Help me fix my Terraform authentication error.

Your server is live on AWS. Do you think you can visit it in your browser? What do you expect to see when you open the public URL?

  • Copy the public_url value from the Terraform output (it looks like http://x.x.x.x) and paste it into your browser's address bar.

The page does not load. Your browser spins and eventually shows a connection timeout.

Connection timeout is expected here

Your server IS running and Apache IS serving that HTML page internally. The problem is that AWS is blocking your request before it reaches the server.

When you launch an instance without specifying a security group, AWS assigns the default security group. The default security group allows all outbound traffic but only allows inbound traffic from other instances in the same security group. Your browser's HTTP request on port 80 is silently dropped.

This is the exact problem you will fix in the next step.

Your first piece of Infrastructure as Code is deployed and running on AWS. Next up, you will fix the connectivity problem by adding a security group that opens port 80 to the world.

Fix the Connectivity Problem

Your EC2 instance is running, but the connection timed out when you tried to visit it in your browser. That timeout is not a bug in your code. It is the most common Cloud Support ticket in AWS: "my instance is unreachable."

The culprit is the default security group. In this step, you will diagnose exactly why HTTP traffic is blocked, create a custom security group that opens the right ports, and watch your web page finally load.

In this step, get ready to:
  • Diagnose why the default security group blocks HTTP traffic.
  • Add a custom security group that allows HTTP and SSH inbound.
  • Attach the security group to your instance and verify the web page loads.
Diagnose why the timeout occurs

When your instance launched in the default VPC, AWS assigned it the default security group. This group has one simple rule: allow all outbound traffic, but only allow inbound traffic from other instances in the same security group.

Your browser sends HTTP traffic on port 80. The default security group does not have a rule that allows port 80 from the internet, so the traffic is silently dropped.

What is a security group?

A security group is a virtual firewall that controls inbound and outbound traffic for your EC2 instance. Each rule specifies a protocol, port range, and source (who can send traffic).

Think of it as a bouncer at the door. If there is no rule on the guest list for HTTP (port 80), the traffic is turned away silently. That is why you see a timeout instead of an explicit error.

Add a security group that allows HTTP and SSH

You will create a custom security group resource that explicitly allows web traffic (port 80) and SSH access (port 22) from any IP address.

  • In VS Code, open main.tf.
  • Find the line resource "aws_instance" "web" {.
  • Add the following security group resource directly above that line, starting with the inbound (ingress) rules:
resource "aws_security_group" "web" {
  name        = "portfolio-web-sg"
  description = "Allow HTTP and SSH inbound"

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

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

What do the ingress rules do?

  • The first ingress block allows TCP traffic on port 80 (HTTP) from any IP address (0.0.0.0/0). This is what lets browsers reach your web page.
  • The second ingress block allows TCP traffic on port 22 (SSH) from any IP address. You will use this later if you need to connect to your server directly.
  • Continue adding this code directly below the second ingress block to define the outbound rule and resource tags:
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "portfolio-web-sg"
  }
}

What does the egress rule do?

The egress block allows all outbound traffic (protocol = "-1" means all protocols, ports 0-0 means all ports). Your instance needs outbound access to download packages and respond to HTTP requests.

  • Save main.tf.

Seeing red squiggles in main.tf?

Make sure the closing } of the security group resource sits on its own line, and that you placed it above the resource "aws_instance" "web" block (not inside it).

help me fix syntax errors in my Terraform security group

Attach the security group and deploy the fix

The security group exists in your code, but your instance does not reference it yet. You need to tell Terraform to attach this security group to your EC2 instance.

  • In main.tf, find the aws_instance resource block. It currently looks like this:
resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type
  user_data     = file("userdata.sh")

  tags = {
    Name = "portfolio-web-server"
  }
}
  • Add the vpc_security_group_ids line below instance_type, so the resource now looks like this:
resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.web.id]
  user_data              = file("userdata.sh")

  tags = {
    Name = "portfolio-web-server"
  }
}

What does vpc_security_group_ids do?

This attribute tells Terraform to attach your custom security group to the instance, replacing the default security group. The value [aws_security_group.web.id] is a reference to the security group resource you just created. Terraform automatically resolves this to the real security group ID at apply time.

  • Save main.tf.

Before you run terraform apply, think about this: will Terraform create a new resource, modify the existing instance, or both?

  • Deploy your changes by running:
terraform apply
  • When Terraform shows the execution plan, type yes and press Enter to confirm.

What should I see?

Terraform shows Plan: 1 to add, 1 to change, 0 to destroy. It creates the new security group (1 to add) and updates the existing instance in-place to use it (1 to change). Your instance keeps running throughout this process.

Now for the moment of truth. Do you think your web page will load this time?

  • Copy the public_url value from the Terraform output (it looks like http://x.x.x.x).
  • Open it in your browser.

You should see your web page displaying "Hello from my Portfolio Web Server!" with "Deployed with Terraform on AWS" below it.

Give it a moment

If the page still times out, wait 30-60 seconds and try again. Security group changes apply almost instantly, but the instance may need a moment to register the updated network rules.

Still seeing a timeout?

  • Confirm the Terraform apply completed without errors. Check that the output shows 2 resources in state.
  • Make sure you are using http:// in the URL, not https://. Your server does not have an SSL certificate.
  • Verify the ingress block for port 80 has cidr_blocks = ["0.0.0.0/0"] (not a typo like 0.0.0.0 without the /0).

help me troubleshoot why my EC2 instance is still unreachable after adding a security group

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf and that your browser is displaying the web page.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point. Confirm your file matches:

provider "aws" {
  region = var.aws_region
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

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

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

Followed by the security group resource:

resource "aws_security_group" "web" {
  name        = "portfolio-web-sg"
  description = "Allow HTTP and SSH inbound"

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

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

Continuing with egress and tags:

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

  tags = {
    Name = "portfolio-web-sg"
  }
}

And finally the instance resource:

resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.web.id]
  user_data              = file("userdata.sh")

  tags = {
    Name = "portfolio-web-server"
  }
}

You just solved the most common EC2 connectivity problem: a missing security group rule. Your server is live, but it is sitting in the default VPC that AWS created for you. Next, you will build a custom VPC from scratch with proper networking that you can explain in any interview.

Architect a Custom VPC

Your web server is live and reachable in the default VPC. That default VPC is a pre-built network AWS creates in every region. It works for testing, but it hides how networking actually works under the hood.

Real Cloud Support and DevOps engineers build custom VPCs with explicit networking. In this step, you will create a custom VPC from scratch with a public subnet, internet gateway, and route table. These are the exact components interviewers ask about.

In this step, get ready to:
  • Define a custom VPC with a public subnet and internet gateway.
  • Wire up a route table so your subnet can reach the internet.
  • Move your EC2 instance into the custom VPC and verify connectivity.
Add the VPC, subnet, and internet gateway

A custom VPC is your own isolated network inside AWS. Inside it, you carve out subnets (IP address ranges) and attach an internet gateway so traffic can flow between your subnet and the public internet.

  • In main.tf, find the line that says resource "aws_security_group" "web". Add the following VPC and subnet resources above that line:
# Create the VPC - your isolated network on AWS
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

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

# 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
  availability_zone       = "${var.aws_region}a"

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

What does this code do?

These two resources define your network's shape:

  • aws_vpc.main creates a VPC with a 10.0.0.0/16 CIDR block. That gives you 65,536 private IP addresses to work with. DNS hostnames are enabled so your instances get readable names.
  • aws_subnet.public carves out a smaller 10.0.1.0/24 range (256 addresses) inside the VPC. Setting map_public_ip_on_launch = true means any instance launched here automatically gets a public IP.
  • Save main.tf. The next chunk adds the internet gateway that connects this network to the outside world.
  • Still in main.tf, add the internet gateway resource directly below the aws_subnet.public block you just added:
# Attach an internet gateway so traffic can flow in and out
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "portfolio-igw"
  }
}

What is an internet gateway?

An internet gateway is the door between your VPC and the public internet. Without it, nothing inside your VPC can send or receive traffic from outside.

Setting vpc_id = aws_vpc.main.id attaches this gateway to your custom VPC.

  • Save main.tf.
  • Preview what Terraform plans to change by running:
terraform plan

You should see Terraform reporting resources to add (your new VPC, subnet, and internet gateway) and resources to destroy and recreate (the security group and instance will be replaced once you wire them in).

Seeing a configuration error?

  • Make sure each resource block has matching opening and closing braces. A missing } is the most common typo.
  • Check that aws_vpc.main.id is spelled exactly as shown. Terraform references are case-sensitive.

Help me fix a Terraform configuration error in my VPC resources

Why build a custom VPC instead of using the default?

The default VPC is convenient but teaches nothing. In interviews for Cloud Support and DevOps roles, candidates are asked to explain subnets, route tables, and internet gateways. Building from scratch forces you to understand each piece.

It also mirrors production practice. Companies never run production workloads in the default VPC because they need control over IP ranges, routing, and isolation.

Add the route table and update existing resources

A subnet with an internet gateway still cannot reach the internet without a route table telling traffic where to go. You also need to move your existing security group and instance into the new VPC.

  • In main.tf, add the route table and its subnet association directly below the aws_internet_gateway.main block:
# Create a route table that sends internet traffic through the 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 = "portfolio-public-rt"
  }
}

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

What does the route table do?

  • The route 0.0.0.0/0 is a catch-all. It means "any traffic not destined for the VPC itself should go through the internet gateway."
  • The association links this routing rule to your specific subnet. Without it, the subnet uses the VPC's default route table, which has no internet route.
  • Save main.tf.

Now you need to point your existing security group and instance at the new VPC. Two small edits wire everything together.

  • In main.tf, scroll down to the aws_security_group "web" resource. Find these lines at the top of the block:
resource "aws_security_group" "web" {
  name        = "portfolio-web-sg"
  description = "Allow HTTP and SSH inbound"
  • Add vpc_id = aws_vpc.main.id on a new line directly below the description line, so it looks like this:
resource "aws_security_group" "web" {
  name        = "portfolio-web-sg"
  description = "Allow HTTP and SSH inbound"
  vpc_id      = aws_vpc.main.id
  • Now scroll down to the aws_instance "web" resource. Find these lines:
resource "aws_instance" "web" {
  ami                    = data.aws_ami.amazon_linux.id
  instance_type          = var.instance_type
  vpc_security_group_ids = [aws_security_group.web.id]
  • Add subnet_id = aws_subnet.public.id on a new line between instance_type and vpc_security_group_ids, so it looks like this:
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]

Why does the instance need a subnet_id?

Previously, your instance launched into the default VPC because you did not specify a subnet. Adding subnet_id tells Terraform to place the instance in your custom VPC's public subnet instead.

Changing the subnet forces Terraform to destroy and recreate the instance. You cannot move a running instance between subnets. Terraform handles this automatically.

  • Save main.tf.
Apply the changes and verify connectivity

Your Terraform code now defines a complete custom network. Time to deploy it and confirm your web server is still reachable from the new VPC.

Before you run this, what do you think Terraform will do to your existing instance? Will it update it in place, or destroy and recreate it?

  • Deploy your updated infrastructure by running:
terraform apply
  • Terraform shows a plan with resources marked -/+ (destroy then create). The security group and instance are replaced because their VPC/subnet changed.
  • Type yes when prompted to confirm.

What does the -/+ symbol mean?

Terraform marks resources with -/+ when it must destroy and recreate them. Changing subnet_id on an EC2 instance forces replacement because AWS cannot move a running instance between subnets. Terraform handles the teardown and rebuild automatically.

You will also see a # forces replacement comment next to the attribute that triggered the recreation.

Once the apply completes, Terraform prints a new public_ip and public_url output. Your old IP is gone because the instance was recreated.

  • Copy the public_url value from the terminal output and open it in your browser.

You should see "Hello from my Portfolio Web Server!" displayed. Your instance is now running inside your custom VPC with proper networking.

Page not loading or connection timed out?

  • Confirm vpc_id = aws_vpc.main.id appears in your aws_security_group block. A security group in a different VPC than the instance will not work.
  • Check that aws_route_table_association links your subnet to the route table. Without it, the subnet has no internet route.
  • Verify map_public_ip_on_launch = true is set on your subnet. Without a public IP, the instance is unreachable from the internet.

My web server is unreachable after moving to a custom VPC in Terraform

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf and that your browser shows the web page from the new IP.

ⓧ I'd like to double check the full code

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

  • provider "aws" block with region = var.aws_region.
  • data "aws_ami" "amazon_linux" block with the Amazon Linux 2023 filter.
  • resource "aws_vpc" "main" with CIDR 10.0.0.0/16 and DNS enabled.
  • resource "aws_subnet" "public" with CIDR 10.0.1.0/24 and map_public_ip_on_launch = true.
  • resource "aws_internet_gateway" "main" attached to aws_vpc.main.id.
  • resource "aws_route_table" "public" with route 0.0.0.0/0 pointing to the internet gateway.
  • resource "aws_route_table_association" "public" linking the subnet and route table.
  • resource "aws_security_group" "web" with vpc_id = aws_vpc.main.id added.
  • resource "aws_instance" "web" with subnet_id = aws_subnet.public.id added.

The outputs.tf, variables.tf, terraform.tf, and userdata.sh files remain unchanged from the previous step.

You now have a properly architected custom VPC with explicit networking. Next up, you will add CloudWatch monitoring so your infrastructure is not just deployed but observed.

Add CloudWatch Monitoring

Your web server is live inside a custom VPC, and anyone can visit it in a browser. That is great for deployment, but what happens if the server starts struggling under load?

A deployed server without monitoring is a liability. In this step, you will add a CloudWatch alarm that watches your server's CPU utilization. Then you will push your completed project to GitHub so you have a portfolio-ready repository.

In this step, get ready to:
  • Add a CloudWatch alarm that triggers when CPU exceeds 80%.
  • Deploy the alarm with Terraform and verify it in the AWS console.
  • Push your Infrastructure as Code to GitHub with a descriptive README.
Add the CloudWatch alarm resource

CloudWatch collects metrics from your EC2 instance automatically. You can define an alarm that evaluates those metrics and transitions to an ALARM state when a threshold is breached.

  • In VS Code, open main.tf.
  • Scroll to the bottom of the file, below the aws_instance resource block.
  • Add the following CloudWatch metric alarm resource:
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
  alarm_name          = "portfolio-high-cpu"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = 2
  metric_name         = "CPUUtilization"
  namespace           = "AWS/EC2"
  period              = 120
  statistic           = "Average"
  threshold           = 80
  alarm_description   = "Alarm when CPU exceeds 80%"

  dimensions = {
    InstanceId = aws_instance.web.id
  }
}

What does this code do?

  • alarm_name sets the display name you will see in the CloudWatch console.
  • metric_name and namespace tell CloudWatch which metric to watch. CPUUtilization in the AWS/EC2 namespace is automatically reported by every EC2 instance.
  • period = 120 means CloudWatch evaluates the metric every 120 seconds (2 minutes).
  • evaluation_periods = 2 means the average CPU must exceed the threshold for two consecutive periods before the alarm fires.
  • dimensions scopes the alarm to your specific instance by referencing aws_instance.web.id.
  • Save main.tf.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

Your main.tf should contain these resource blocks in order:

  • provider "aws" block.
  • data "aws_ami" "amazon_linux" data source.
  • aws_vpc.main resource.
  • aws_subnet.public resource.
  • aws_internet_gateway.main resource.
  • aws_route_table.public resource.
  • aws_route_table_association.public resource.
  • aws_security_group.web resource.
  • aws_instance.web resource.
  • aws_cloudwatch_metric_alarm.high_cpu resource (the one you just added).

Scroll through your file and confirm each block is present. If any are missing, revisit the prior steps to add them.

Deploy and verify the alarm

Before you run this, think about what you expect Terraform to do. You already have 7 resources in state. What will the plan show?

  • Deploy the alarm by running this command:
terraform apply

What should I see?

Terraform shows a plan to add 1 new resource (the CloudWatch alarm) with 0 changes and 0 destructions. Type yes when prompted to confirm.

  • After the apply completes, confirm the alarm exists by navigating to the CloudWatch console in your browser.
  • Open https://console.aws.amazon.com/cloudwatch/ in a new browser tab.
  • In the left navigation pane, click Alarms, then All alarms.

You should see an alarm named portfolio-high-cpu in the Insufficient data state. This is normal. CloudWatch needs a few minutes of metric data before it can evaluate the threshold. It will transition to OK on its own.

Don't see the alarm?

  • Make sure your AWS console region matches the region in your Terraform configuration (us-east-1). Check the region selector in the top-right corner of the console.
  • Re-run terraform apply and look for errors. A typo in the resource block (like a missing closing brace) will prevent creation.

Having trouble? Why isn't my CloudWatch alarm showing up in the console?

Push your project to GitHub

Your Terraform code is the portfolio artifact. Pushing it to GitHub gives you a link you can share on your resume and walk through in interviews.

  • Create a .gitignore file to keep Terraform state and provider binaries out of your repository. In your terminal, run:
echo ".terraform/
*.tfstate
*.tfstate.backup" > .gitignore

Why ignore these files?

The .terraform/ directory contains downloaded provider binaries (hundreds of megabytes). State files (*.tfstate) can contain sensitive values like IP addresses and resource IDs. Neither belongs in version control.

  • Create a README.md file that describes your project. In VS Code, create a new file called README.md and paste the following:
# Portfolio Web Server - Terraform on AWS

Infrastructure as Code project that deploys a monitored web server on AWS.

## Architecture

- Custom VPC with public subnet and internet gateway
- EC2 instance running Apache (Amazon Linux 2023)
- Security group allowing HTTP and SSH access
- CloudWatch alarm monitoring CPU utilization

## Usage

Run `terraform init` to initialize, then `terraform apply` to deploy.

## Clean Up

Run `terraform destroy` to remove all resources.
  • Save README.md.
  • Confirm all your project files are present by running:
ls

You should see main.tf, variables.tf, terraform.tf, outputs.tf, userdata.sh, README.md, and .gitignore listed.

  • Now create a new repository on GitHub. Open https://github.com/new in your browser.
  • Enter terraform-aws-web-server as the repository name.
  • Leave the repository set to Public.
  • Do NOT check any boxes for README, .gitignore, or license (you already have these locally).
  • Click Create repository.

GitHub shows a quick setup page with commands. You will use those commands to connect your local repo to GitHub.

  • Back in your terminal, add the remote, stage all files, commit, and push by running these commands:
git add .
git commit -m "Deploy monitored web server with Terraform"
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/terraform-aws-web-server.git"]]
git push -u origin main

What do these commands do?

  • git add . stages all files (respecting .gitignore).
  • git commit saves a snapshot of your project.
  • git remote add origin links your local repo to the GitHub repository you just created.
  • git push -u origin main uploads your code to GitHub and sets main as the default upstream branch.

Push rejected or authentication error?

  • If Git asks for credentials, you may need to set up a personal access token or SSH key for GitHub authentication.
  • If the push is rejected because of a branch name mismatch, run git branch -M main to rename your branch, then try pushing again.

Still stuck? Help me fix my git push to GitHub.

  • Refresh your GitHub repository page in the browser.

You should see all your Terraform files and the README rendered on the repository home page.

Your infrastructure is now code, monitored, and version-controlled. You have a live web server, a CloudWatch alarm watching its health, and a GitHub repository you can link on your resume. Next up, the Secret Mission challenges you to wire up email alerts and prove the full monitoring pipeline works end-to-end.

Secret mission

Wire Up Email Alerts and Trigger Them

A monitoring alarm nobody sees is useless. In this mission, you will wire up SNS email notifications to your CloudWatch alarm, then SSH into your server and stress-test the CPU to prove the full alerting pipeline works end-to-end.

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. Your EC2 instance consumes free tier hours while running, and other resources like the VPC and CloudWatch alarm stay within free tier limits.

Cost warning

Your t3.micro instance uses your free tier budget of roughly 750 hours per month. One instance running 24/7 uses approximately 720 hours, leaving minimal margin. If your free tier expires or you run multiple instances, you will be charged On-Demand rates.

If you are not actively using the server, run terraform destroy to remove all AWS resources immediately.

Resources you used:

  • EC2 instance (t3.micro, portfolio-web-server).
  • VPC (portfolio-vpc) with public subnet, internet gateway, and route table.
  • Security group (portfolio-web-sg).
  • CloudWatch alarm (portfolio-high-cpu).
  • SNS topic (portfolio-alerts) and email subscription.
  • Local Terraform project files on your Desktop.
  • Terraform (installed via Homebrew).
  • AWS CLI v2 (installed via .pkg installer).

Keep everything running

No action needed. Choose this if you are still actively building or want the server available for interviews and demonstrations.

  • Your EC2 instance stays running and accessible at its public IP.
  • Your CloudWatch alarm continues monitoring CPU utilization.
  • Your SNS email subscription stays active.
  • Monitor your AWS billing dashboard to stay within free tier limits.

Pause - I'll come back to this later

Stop the EC2 instance to preserve free tier hours while keeping your infrastructure defined in Terraform state. You can restart it later without redeploying.

  • Get your instance ID by running:
  • Stop the instance by running:
terraform state show aws_instance.web | grep "^  id"

Copy the instance ID from the output. Then stop the instance:

aws ec2 stop-instances --instance-ids i-your-instance-id-here
  • A stopped instance does not consume free tier compute hours.
  • Your VPC, security group, CloudWatch alarm, and SNS resources remain in place at no cost.
  • When you are ready to resume, start the instance again from the EC2 console or run aws ec2 start-instances --instance-ids with the same ID.

Delete - I don't want to use this again

Remove all AWS resources and local files. Your Terraform code on GitHub remains as your portfolio artifact.

  • Destroy all AWS resources by running this from your project folder:
terraform destroy

Terraform will show you a plan listing all 10 resources to be destroyed. Type yes when prompted to confirm.

  • Remove your local project folder from the Desktop:
cd ~/Desktop
rm -rf terraform-aws-portfolio

On Windows, use:

cd ~\Desktop
Remove-Item -Recurse -Force terraform-aws-portfolio
  • Uninstall Terraform if you no longer need it:
brew uninstall hashicorp/tap/terraform
  • Uninstall the AWS CLI if you no longer need it:
sudo rm /usr/local/bin/aws
sudo rm /usr/local/bin/aws_completer
sudo rm -rf /usr/local/aws-cli

What about your GitHub repository?

Your GitHub repository is your portfolio artifact. Keep it to show interviewers your Infrastructure as Code. Deleting local files and AWS resources does not affect the repository.

Nice Work!

Nice Work!

Nice work! You just deployed a fully monitored web server on AWS, defined entirely in Terraform code that lives in your GitHub repository.

You've learned how to:

  • Deploy a live web server on AWS EC2 using Terraform as Infrastructure as Code, creating and destroying your entire stack with a single command.
  • Diagnose and fix a real networking problem (a security group blocking inbound HTTP traffic) that mirrors the most common Cloud Support tickets.
  • Build a custom VPC from scratch with a public subnet, internet gateway, and route table.
  • Secret Mission: Wire up SNS email notifications and trigger the full alerting pipeline with a stress test to prove end-to-end monitoring works.

Ready to quiz yourself?