Deploy a Web Server with Terraform

Define AWS infrastructure as code and deploy a live EC2 web server.

Introduction

30 Second Summary

Every time you click through the AWS console to create a server, you're performing a manual step that nobody can review, repeat, or roll back. Infrastructure as Code solves this by letting you define your entire cloud setup in files you can version and rerun.

In this project, you will write Terraform configuration files that deploy a complete web server on AWS. One command creates your entire infrastructure. Another command tears it all down.

What You'll Build

A fully functional web server on AWS, with all its networking infrastructure defined entirely in Terraform code.

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

  • A VPC networking stack defined as code, including a public subnet, internet gateway, and route table.
  • A running EC2 web server you can visit in your browser at a public URL.
  • Hands-on experience with the full Terraform lifecycle: init, plan, apply, modify, and destroy.
  • Secret Mission: Refactor your VPC resources into a reusable Terraform module.

Are there any prerequisites?

This project assumes you have an AWS account with the AWS CLI configured, plus Terraform and Git installed on your machine. Step 1 walks you through verifying everything is ready.

Verify Your Environment

Before writing any infrastructure code, you need to confirm that your tools are working correctly. Terraform talks to AWS on your behalf, so both Terraform and the AWS CLI need to be installed and authenticated.

In this step, you will verify your tools, create a project directory, and set up the Git repository that tracks your infrastructure code.

In this step, get ready to:
  • Verify Terraform and AWS CLI are working.
  • Create your project directory with Git.
  • Set up the Terraform file structure.
Verify Terraform is installed and check the version

Terraform needs to be version 1.15 or higher for this project. Let's confirm it is installed and ready.

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

✔️ I see version 1.15 or higher

You should see output like Terraform v1.15.6 (or any 1.15.x version). Terraform is ready to go.

ⓧ I see an older version

If your version is below 1.15, you need to upgrade.

  • Upgrade Terraform by running this command:
brew upgrade hashicorp/tap/terraform
  • Run terraform version again to confirm you now see v1.15.x.

ⓧ Command not found

Terraform is not installed yet. Install it with Homebrew by running these commands:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform
  • Run terraform version again to confirm you see v1.15.x.

Now confirm that your AWS CLI is authenticated and can reach your AWS account.

  • Verify your AWS identity by running this command:
aws sts get-caller-identity

✔️ I see my account details

You should see a JSON response containing your UserId, Account, and Arn. This confirms your CLI can talk to AWS.

ⓧ I see an error or "Unable to locate credentials"

Your AWS CLI is not configured with valid credentials.

  • Run aws configure and enter your Access Key ID, Secret Access Key, default region (us-east-1), and default output format (json).
  • Run aws sts get-caller-identity again to confirm you now see your account details.

Where to find your credentials

Your Access Key ID and Secret Access Key come from the IAM console in AWS. If you have not created an access key before, navigate to IAM in the AWS console, select your user, and create one under the Security credentials tab.

What about the AWS region?

This project uses us-east-1 as its default region. You will define this directly in your Terraform configuration in the next step, so it does not matter what your AWS CLI default region is set to.

Create the project directory and initialize Git

Every Terraform project lives in its own directory. You will also track changes with Git so you have a history of your infrastructure code.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir terraform-web-server
cd terraform-web-server
  • Initialize a Git repository by running this command:
git init
  • Confirm the folder was created by running:
ls ~/Desktop
  • You should see terraform-web-server in the output.

Don't see the folder?

Make sure you ran cd ~/Desktop before mkdir. If you skipped that step, the folder may have been created in your home directory instead.

help me find where my terraform-web-server folder was created

Next, create a .gitignore file so Git does not track Terraform's generated files.

  • Create the .gitignore file by running this command:
cat << 'EOF' > .gitignore
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
EOF

What does this .gitignore do?

  • .terraform/ is a directory Terraform creates to store downloaded provider plugins. It can be hundreds of megabytes.
  • *.tfstate and *.tfstate.backup are state files that contain sensitive resource details. Never commit these to version control.
  • .terraform.lock.hcl is a lock file that records exact provider versions. It is safe to commit, but this project excludes it for simplicity.
  • Verify the file was created by running:
cat .gitignore
  • You should see the four entries printed in your terminal.

File is empty or missing?

Make sure you are inside the terraform-web-server directory. Run pwd to check your current location. You should see a path ending in terraform-web-server.

help me troubleshoot my .gitignore file creation

✔️ Awesome, I've got everything!

Great. Make sure you have saved your .gitignore file and you are still inside the terraform-web-server directory.

ⓧ I'd like to double check the full code

Your .gitignore should contain exactly this:

.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
Create the initial Terraform file structure

Terraform projects follow a standard file layout. You will create three files that separate your configuration into logical pieces.

  • Create the three Terraform files by running this command:
touch main.tf variables.tf outputs.tf

Why three files?

  • main.tf holds your resource definitions (the infrastructure you want to create).
  • variables.tf declares input variables so you can reuse your configuration across environments.
  • outputs.tf defines values Terraform should display after a successful deployment (like your server's URL).
  • Confirm the files exist by running:
ls
  • You should see .gitignore, main.tf, outputs.tf, and variables.tf in the output.

Missing a file?

Double check that you ran the touch command from inside the terraform-web-server directory. If you accidentally created the files elsewhere, delete them and run touch main.tf variables.tf outputs.tf again from the correct location.

help me find where my Terraform files were created

Your project structure is ready. Next up, you will write your first Terraform configuration and define the network infrastructure for your web server.

Define the Network Infrastructure

Your environment is ready and your project files are in place. Now it's time to write actual infrastructure code.

Every web server needs a network to live on. In AWS, that means creating a VPC (Virtual Private Cloud) with subnets, an internet gateway, and routing rules. Instead of clicking through the AWS console to create each piece, you will define them all in Terraform configuration files.

In this step, get ready to:
  • Write the Terraform and AWS provider configuration.
  • Define a VPC, public subnet, and internet gateway.
  • Create a route table that sends traffic to the internet.
Write the Terraform and provider configuration

Every Terraform project starts with a terraform block that declares which version of Terraform and which providers are required. This block acts as the foundation for everything else you write.

  • In your code editor, open main.tf inside the terraform-web-server folder.
  • Add the Terraform configuration and AWS provider blocks by pasting this code:
terraform {
  required_version = ">= 1.15"

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

provider "aws" {
  region = var.aws_region
}

What does this code do?

  • required_version = ">= 1.15" ensures anyone running this code has Terraform 1.15 or newer installed.
  • required_providers tells Terraform to download the AWS provider from HashiCorp's registry. The ~> 6.0 constraint means any 6.x version is acceptable.
  • provider "aws" configures the AWS provider with a region. The var.aws_region reference pulls the value from a variable you will define next.

You won't see a change yet. The next chunk defines the variable that var.aws_region points to.

  • Open variables.tf and define the project's input variables by pasting 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     = "t2.micro"
}

What does this code do?

Variables make your configuration flexible. Instead of hardcoding us-east-1 everywhere, you define it once here and reference it with var.aws_region.

The instance_type variable will be used in a later step when you create the EC2 instance. Setting the default to t2.micro keeps you within the AWS Free Tier.

  • Save both main.tf and variables.tf.
  • Initialize the Terraform project by running this command:
terraform init
  • You should see Terraform has been successfully initialized! in the output.

What does terraform init do?

terraform init reads your required_providers block, downloads the AWS provider plugin, and stores it in the .terraform/ directory.

You only need to run it once per project (or again if you add new providers). Your .gitignore already excludes .terraform/ so the downloaded plugin does not end up in version control.

Seeing an error during init?

  • Check that your main.tf has matching opening and closing braces for every block. A missing } is the most common syntax error.
  • Make sure your terminal is inside the terraform-web-server directory. Run pwd to confirm.
  • Confirm your AWS CLI is authenticated by running aws sts get-caller-identity again.

Help me debug my terraform init error.

Define the VPC, subnet, and internet gateway

A VPC is your own isolated network inside AWS.

Inside the VPC you create a subnet (a smaller IP range where your server will live) and an internet gateway (the door that connects your private network to the public internet).

  • In main.tf, add the VPC resource below the provider "aws" block by pasting this code:
# --- Networking ---

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

  tags = {
    Name      = "terraform-web-server-vpc"
    ManagedBy = "Terraform"
  }
}

What does this code do?

  • resource "aws_vpc" "main" declares a VPC resource. Terraform will create it in your AWS account.
  • cidr_block = "10.0.0.0/16" gives your VPC a large private IP range (over 65,000 addresses).
  • enable_dns_hostnames and enable_dns_support let instances in this VPC receive DNS names, which makes them easier to reach.
  • tags label the resource in the AWS console so you can find it later.

You won't see a change yet. The next chunk adds the subnet and internet gateway that complete the network.

  • Still in main.tf, add the subnet and internet gateway below the VPC resource:
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 = "terraform-web-server-public-subnet"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "terraform-web-server-igw"
  }
}

What does this code do?

  • aws_subnet.public carves out a smaller range (10.0.1.0/24, 256 addresses) inside your VPC. This is where your server will live.
  • map_public_ip_on_launch = true means any instance launched in this subnet automatically gets a public IP address.
  • aws_internet_gateway.main attaches an internet gateway to the VPC. Without this, nothing inside the VPC can reach the internet.
  • aws_vpc.main.id is a reference. Terraform automatically figures out it must create the VPC first, then the subnet and gateway.

You won't see a change yet. The next chunk adds routing so traffic knows how to reach the internet.

Define the route table and association

A subnet with an internet gateway still cannot reach the internet until a route table tells traffic where to go. Think of the route table as a set of directions posted at every intersection in your network.

  • In main.tf, add the route table and its association below the internet gateway resource:
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-server-public-rt"
  }
}

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

What does this code do?

  • The route inside aws_route_table.public says: "any traffic destined for 0.0.0.0/0 (anywhere on the internet) should go through the internet gateway."
  • aws_route_table_association.public links the route table to your subnet. Without this link, the subnet would use a default route table with no internet route.
  • Save main.tf.
  • Validate your entire configuration by running this command:
terraform validate
  • You should see Success! The configuration is valid. in the output.

Seeing a validation error?

  • Check for typos in resource references. For example, aws_vpc.main.id must match the resource name "main" you gave your VPC.
  • Make sure every opening brace { has a matching closing brace }. Nested blocks (like route { } inside the route table) are easy to misclose.
  • Confirm you ran terraform init earlier. Validation requires the provider plugin to be downloaded first.

Help me fix my terraform validate error.

Why validate before applying?

terraform validate checks syntax and internal consistency without contacting AWS.

Catching errors here is free and instant. Catching them during terraform apply means waiting for AWS API calls to fail, which is slower and harder to debug.

Double check that your files match the full code below.

✔️ Awesome, I've got everything!

Great. Make sure both main.tf and variables.tf are saved.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.15"

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

provider "aws" {
  region = var.aws_region
}

# --- Networking ---

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

  tags = {
    Name      = "terraform-web-server-vpc"
    ManagedBy = "Terraform"
  }
}

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 = "terraform-web-server-public-subnet"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "terraform-web-server-igw"
  }
}

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-server-public-rt"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}
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     = "t2.micro"
}

Your network foundation is complete. Next up, you will add an EC2 instance that runs a web server inside this network.

Add the EC2 Web Server

Your network is ready with a VPC, subnet, and internet gateway. But a network without a server is like a highway without any buildings at the exits.

In this step, you will define the compute layer: a security group that controls traffic, and an EC2 instance that runs a web server. You will also tell Terraform what information to display after deployment using outputs.

In this step, get ready to:
  • Define a security group with HTTP access rules.
  • Launch an EC2 instance with a web server startup script.
  • Add outputs to display the server's public URL.
Define the security group

A security group acts as a virtual firewall for your EC2 instance. It controls which traffic is allowed in and which traffic is allowed out.

  • In your code editor, open main.tf.
  • Add the following code at the bottom of the file, below the aws_route_table_association resource:
# --- Security ---

resource "aws_security_group" "web_server" {
  name        = "terraform-web-server-sg"
  description = "Allow HTTP inbound and all outbound"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "terraform-web-server-sg"
  }
}

What does this code do?

This creates an empty security group attached to your VPC. The name and description identify it in the AWS console.

Notice there are no ingress or egress blocks inside the resource. You will define rules as separate resources next.

You won't see a change yet. The next chunk adds the rules that complete this security group.

Add the security group rules

Without rules, the security group blocks everything. You need an ingress rule to allow web traffic in on port 80, and an egress rule to let the instance reach the internet for package downloads.

  • Add the following code directly below the aws_security_group resource you just added:
resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web_server.id
  description       = "Allow HTTP from anywhere"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"
}

resource "aws_vpc_security_group_egress_rule" "all_outbound" {
  security_group_id = aws_security_group.web_server.id
  description       = "Allow all outbound traffic"
  ip_protocol       = "-1"
  cidr_ipv4         = "0.0.0.0/0"
}

What does this code do?

The ingress rule opens TCP port 80 (HTTP) to all IP addresses (0.0.0.0/0). This lets anyone on the internet view your web page.

The egress rule uses ip_protocol = "-1" which means all protocols. This lets the instance make outbound connections to download packages during setup.

Why separate rule resources instead of inline blocks?

The AWS Terraform provider recommends using aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule as separate resources instead of inline ingress/egress blocks on the security group.

Inline rules have known management issues and are harder to modify individually. Separate resources follow current best practice and give you granular control over each rule.

  • Save main.tf.
  • Confirm the syntax is valid by running:
terraform validate

You should see Success! The configuration is valid. in the output.

Seeing a validation error?

Check that each resource block has matching opening and closing braces. Make sure security_group_id in both rules references aws_security_group.web_server.id exactly.

Help me fix a terraform validate error in my security group rules.

Define the AMI data source

An AMI (Amazon Machine Image) is the template that defines what operating system your instance runs. Instead of hardcoding an AMI ID that changes per region, you will use a data source to look up the latest Amazon Linux 2023 image automatically.

  • Add the following code below the egress rule in main.tf:
# --- Compute ---

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?

A data block reads information from AWS without creating anything. This one searches for the most recent AMI owned by Amazon that matches the name pattern al2023-ami-2023*-x86_64.

The most_recent = true setting ensures you always get the latest patched version. The hvm filter selects hardware-virtualized images, which is what modern instance types require.

You won't see a change yet. The next chunk uses this AMI to launch the instance.

Define the EC2 instance

This is the core of your infrastructure. The EC2 instance uses the AMI you just looked up, runs in your public subnet, and executes a startup script that installs Apache and serves a custom HTML page.

  • Add the following code directly below the aws_ami data source:
resource "aws_instance" "web_server" {
  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_server.id]

  user_data = <<-EOF
    #!/bin/bash
    dnf update -y
    dnf install -y httpd
    systemctl start httpd
    systemctl enable httpd
    echo "<h1>Hello from Terraform!</h1><p>This infrastructure was defined as code.</p>" > /var/www/html/index.html
  EOF

  tags = {
    Name      = "terraform-web-server"
    ManagedBy = "Terraform"
  }
}

What does this code do?

The instance pulls its operating system from data.aws_ami.amazon_linux.id and uses var.instance_type (defaulting to t2.micro which is covered by AWS Free Tier).

The user_data block is a bash script that runs once when the instance first boots. It installs the Apache web server (httpd), starts it, enables it to survive reboots, and writes a custom HTML page.

The vpc_security_group_ids argument attaches the security group you defined earlier, allowing HTTP traffic to reach Apache on port 80.

  • Save main.tf.
  • Validate the full file by running:
terraform validate

You should see Success! The configuration is valid. confirming that all your resources reference each other correctly.

Seeing an error about the AMI or instance?

Make sure the data block uses data "aws_ami" "amazon_linux" and the instance references it as data.aws_ami.amazon_linux.id. The word data must appear in both places.

Help me fix a terraform validate error with my EC2 instance or AMI data source.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf with all the security and compute resources.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.15"

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

provider "aws" {
  region = var.aws_region
}

# --- Networking ---

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

  tags = {
    Name      = "terraform-web-server-vpc"
    ManagedBy = "Terraform"
  }
}

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 = "terraform-web-server-public-subnet"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "terraform-web-server-igw"
  }
}

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-server-public-rt"
  }
}

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

# --- Security ---

resource "aws_security_group" "web_server" {
  name        = "terraform-web-server-sg"
  description = "Allow HTTP inbound and all outbound"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "terraform-web-server-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web_server.id
  description       = "Allow HTTP from anywhere"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"
}

resource "aws_vpc_security_group_egress_rule" "all_outbound" {
  security_group_id = aws_security_group.web_server.id
  description       = "Allow all outbound traffic"
  ip_protocol       = "-1"
  cidr_ipv4         = "0.0.0.0/0"
}

# --- Compute ---

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_server" {
  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_server.id]

  user_data = <<-EOF
    #!/bin/bash
    dnf update -y
    dnf install -y httpd
    systemctl start httpd
    systemctl enable httpd
    echo "<h1>Hello from Terraform!</h1><p>This infrastructure was defined as code.</p>" > /var/www/html/index.html
  EOF

  tags = {
    Name      = "terraform-web-server"
    ManagedBy = "Terraform"
  }
}
Add outputs

Outputs tell Terraform to display specific values after a successful terraform apply. You will output the web server URL so you can visit it in your browser without digging through the AWS console.

  • Open outputs.tf in your code editor and add the following code:
output "web_server_url" {
  description = "URL of the web server"
  value       = "http://${aws_instance.web_server.public_ip}"
}

output "instance_id" {
  description = "EC2 instance ID"
  value       = aws_instance.web_server.id
}

What does this code do?

The web_server_url output constructs a full URL using the instance's public IP. After terraform apply finishes, you can copy this URL straight into your browser.

The instance_id output gives you the EC2 instance ID, which is useful for debugging in the AWS console.

  • Save outputs.tf.
  • Validate one final time by running:
terraform validate
  • Preview the full execution plan by running:
terraform plan

You should see a summary line that says Plan: 9 to add, 0 to change, 0 to destroy. This confirms Terraform knows how to create all nine resources: VPC, subnet, internet gateway, route table, route table association, security group, two rules, and the EC2 instance.

Not seeing 9 resources in the plan?

If the count is lower, a resource block is likely missing or has a syntax error that prevented it from being parsed. Scroll up through the plan output to see which resources are listed.

If you see an error instead of a plan, check that outputs.tf references aws_instance.web_server.public_ip and aws_instance.web_server.id exactly.

Help me debug why terraform plan isn't showing 9 resources.

✔️ Awesome, I've got everything!

Great. Double check you have saved outputs.tf with both output blocks.

ⓧ I'd like to double check the full code

output "web_server_url" {
  description = "URL of the web server"
  value       = "http://${aws_instance.web_server.public_ip}"
}

output "instance_id" {
  description = "EC2 instance ID"
  value       = aws_instance.web_server.id
}

Your infrastructure is fully defined as code. Nine resources are queued up and waiting to be created. Next up, you will deploy everything to AWS and visit your live web server in the browser.

Deploy and Test

Your Terraform configuration is complete. Every resource you need is defined in code, validated, and ready to go.

Now it's time to make it real. In this step, you'll run terraform apply to provision actual AWS infrastructure, verify your web server is live, and explore how Terraform tracks everything in its state file.

In this step, get ready to:
  • Apply your Terraform configuration to create 9 AWS resources.
  • Verify your web server is live and accessible in the browser.
  • Explore Terraform's state file to see how it tracks your infrastructure.
Review and apply the infrastructure

Before Terraform creates anything, it shows you an execution plan. This is your last chance to review what will be created before real resources appear in your AWS account.

  • Preview the execution plan by running this command:
terraform plan

You should see a summary at the bottom showing Plan: 9 to add, 0 to change, 0 to destroy. with a + symbol next to each resource that will be created.

What does this output mean?

The + symbol means "create". Terraform is telling you it will create 9 new resources. The plan also shows the exact values for each attribute, with (known after apply) for values that AWS assigns at creation time (like IDs and IP addresses).

  • Deploy the infrastructure by running this command:
terraform apply

What happens when you run this?

Terraform displays the full plan again and asks for confirmation. You will see a prompt that says Do you want to perform these actions? followed by Enter a value: at the bottom.

  • Type yes and press Enter to confirm.

Terraform begins creating resources. You'll see progress updates as each resource is created. The entire process typically takes 30-60 seconds.

When it finishes, you should see Apply complete! Resources: 9 added, 0 changed, 0 destroyed. followed by your outputs.

Seeing an error during apply?

If you see an error about invalid credentials, confirm your AWS CLI is authenticated by running aws sts get-caller-identity.

If you see an error about a VPC limit or subnet conflict, you may have existing resources in us-east-1 from a previous project. Check your AWS console for leftover VPCs.

Ask for help: Why is my terraform apply failing?

Verify the deployment

Your infrastructure is live. The terraform apply output includes your web_server_url value, which is the public HTTP address of your new web server.

  • Find the web_server_url output in your terminal. It looks like http:// followed by an IP address.
  • Copy that URL and paste it into your browser's address bar.
  • Confirm you see a page that says Hello from Terraform! with the text "This infrastructure was defined as code." below it.

Don't see the page immediately?

It may take 1-2 minutes for the EC2 instance to finish bootstrapping. The user_data script needs to install and start Apache after the instance launches. Wait a moment and refresh your browser.

Still seeing a connection error after 2 minutes?

Make sure you are using http:// (not https://). Your security group only allows traffic on port 80 (HTTP).

If the page still won't load, verify that your security group's ingress rule allows TCP port 80 from 0.0.0.0/0 by checking your main.tf file.

Ask for help: Why can't I reach my EC2 web server in the browser?

Explore the Terraform state

Terraform tracks every resource it manages in a local state file. This is how it knows what exists in your AWS account and what needs to change on the next apply.

  • List all managed resources by running this command:
terraform state list

You should see all 9 resources listed, one per line. The output includes both your managed resources and the data source:

data.aws_ami.amazon_linux
aws_instance.web_server
aws_internet_gateway.main
aws_route_table.public
aws_route_table_association.public
aws_security_group.web_server
aws_subnet.public
aws_vpc.main
aws_vpc_security_group_egress_rule.all_outbound
aws_vpc_security_group_ingress_rule.http

What is Terraform state?

The state file is Terraform's record of what it has created. It maps your configuration resources to real AWS resource IDs. Without this file, Terraform would not know which real-world resources correspond to which blocks in your code.

This is also why .tfstate files should never be committed to version control. They contain sensitive information like resource IDs and can contain secrets.

  • Inspect the details of your EC2 instance by running this command:
terraform state show aws_instance.web_server

You should see a detailed view of your instance's attributes, including its AMI ID, instance type, public IP address, subnet, and security groups.

Why inspect state?

The terraform state show command is useful for debugging. It reveals the actual values AWS assigned to your resource, including generated IDs, availability zones, and network interfaces that you never explicitly configured.

Your web server is live and serving traffic from infrastructure you defined entirely in code. Next up, you'll modify the infrastructure and then destroy it to prove you have full lifecycle control.

Modify and Destroy

Your web server is live and serving traffic from infrastructure you defined entirely in code. Now you'll experience the rest of the Terraform lifecycle: modifying running infrastructure and tearing it all down.

One of the most powerful aspects of Infrastructure as Code is that updating a live system is the same workflow as creating it. Change the code, review the plan, apply. Terraform figures out what needs to be replaced versus what can be updated in place.

In this step, get ready to:
  • Modify your web server's HTML by changing the Terraform code.
  • Destroy all 9 AWS resources with a single command.
  • Commit your Terraform code to Git as a portfolio artifact.
Modify the infrastructure

When you change an attribute that cannot be updated in place (like user_data), Terraform will destroy the old resource and create a new one. This is called a replacement.

  • In main.tf, scroll down to the user_data block inside aws_instance.web_server.
  • Find this line:
    echo "<h1>Hello from Terraform!</h1><p>This infrastructure was defined as code.</p>" > /var/www/html/index.html
  • Replace the HTML content so the line now looks like this:
    echo "<h1>Updated with Terraform!</h1><p>Infrastructure changes are just code changes.</p>" > /var/www/html/index.html
  • Save main.tf.
  • Preview the change by running this command:
terraform plan

You should see Terraform report that aws_instance.web_server must be replaced with a -/+ prefix. The plan summary shows 1 to add, 0 to change, 1 to destroy.

Why does Terraform replace the instance?

The user_data attribute is a bootstrap script that runs only once when an EC2 instance first launches. AWS cannot update it on a running instance.

Terraform detects this constraint and plans a destroy-then-create cycle. The old instance is terminated and a brand new one is launched with the updated script.

  • Apply the change by running:
terraform apply
  • Terraform will show the execution plan and ask for confirmation. Type yes and press Enter.
  • Wait for the apply to complete (typically 30-60 seconds).
  • Copy the new web_server_url output and open it in your browser.

You should see your updated HTML page after 1-2 minutes of instance bootstrapping. Notice the URL has a different IP address because Terraform created an entirely new instance.

Still seeing the old page?

The new instance needs a minute or two to finish running the user_data bootstrap script. Wait and refresh. Make sure you are using the NEW URL from the terraform apply output, not the old one.

help me troubleshoot why my updated Terraform web page isn't showing

Destroy all resources

The terraform destroy command is the inverse of terraform apply. It terminates every resource Terraform is managing in a single operation.

  • Remove all your AWS resources by running:
terraform destroy
  • Terraform will display a destruction plan listing all 9 resources. Type yes when prompted and press Enter.
  • Wait for the destroy to complete. You should see Destroy complete! Resources: 9 destroyed. in the output.

What does terraform destroy do?

Terraform reads its state file, builds a dependency graph, and deletes resources in the correct order. For example, it terminates the EC2 instance before removing the subnet it depends on, and removes the subnet before deleting the VPC.

After a successful destroy, your AWS account is back to its original state with no lingering resources or charges.

  • Confirm the state is empty by running:
terraform state list

The command should return no output at all. This confirms Terraform is no longer managing any resources.

Destroy failed or timed out?

If the destroy hangs or fails, run terraform destroy again. Terraform is idempotent and will pick up where it left off. If a specific resource fails to delete, check the AWS console to confirm it was already removed.

help me troubleshoot a failed terraform destroy

Commit your code to Git

Your Terraform files are your portfolio artifact. They prove you can define and manage cloud infrastructure as code.

  • Before committing, revert the temporary HTML change in main.tf so your committed code represents the clean working state.
  • Find this line in the user_data block:
    echo "<h1>Updated with Terraform!</h1><p>Infrastructure changes are just code changes.</p>" > /var/www/html/index.html
  • Change it back to the original:
    echo "<h1>Hello from Terraform!</h1><p>This infrastructure was defined as code.</p>" > /var/www/html/index.html
  • Save main.tf.
  • Stage and commit all your Terraform files by running these commands:
git add main.tf variables.tf outputs.tf .gitignore
git commit -m "Add Terraform web server infrastructure"
  • Verify your commit by running:
git log --oneline

You should see your commit message in the log output. This is your infrastructure-as-code artifact, ready to share in interviews or build upon in future projects.

Git says nothing to commit?

Make sure you saved main.tf after reverting the HTML change. Run git status to see which files have changes. If all files show as untracked, you may need to run git init first (this was done in Step 1).

help me fix my git commit issue in my Terraform project

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files and your git log shows the commit.

ⓧ I'd like to double check the full code

Here is the complete main.tf for comparison:

terraform {
  required_version = ">= 1.15"

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

provider "aws" {
  region = var.aws_region
}

# --- Networking ---

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

  tags = {
    Name      = "terraform-web-server-vpc"
    ManagedBy = "Terraform"
  }
}

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 = "terraform-web-server-public-subnet"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "terraform-web-server-igw"
  }
}

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-server-public-rt"
  }
}

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

# --- Security ---

resource "aws_security_group" "web_server" {
  name        = "terraform-web-server-sg"
  description = "Allow HTTP inbound and all outbound"
  vpc_id      = aws_vpc.main.id

  tags = {
    Name = "terraform-web-server-sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web_server.id
  description       = "Allow HTTP from anywhere"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"
}

resource "aws_vpc_security_group_egress_rule" "all_outbound" {
  security_group_id = aws_security_group.web_server.id
  description       = "Allow all outbound traffic"
  ip_protocol       = "-1"
  cidr_ipv4         = "0.0.0.0/0"
}

# --- Compute ---

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_server" {
  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_server.id]

  user_data = <<-EOF
    #!/bin/bash
    dnf update -y
    dnf install -y httpd
    systemctl start httpd
    systemctl enable httpd
    echo "<h1>Hello from Terraform!</h1><p>This infrastructure was defined as code.</p>" > /var/www/html/index.html
  EOF

  tags = {
    Name      = "terraform-web-server"
    ManagedBy = "Terraform"
  }
}

Here is variables.tf:

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     = "t2.micro"
}

Here is outputs.tf:

output "web_server_url" {
  description = "URL of the web server"
  value       = "http://${aws_instance.web_server.public_ip}"
}

output "instance_id" {
  description = "EC2 instance ID"
  value       = aws_instance.web_server.id
}

Here is .gitignore:

.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl

You've completed the full Terraform lifecycle: init, plan, apply, modify, and destroy. Next up, take on the Secret Mission to refactor your infrastructure into reusable modules.

Secret mission

Modularize Your VPC

Your infrastructure works, but it's all in one file. In production, teams package reusable infrastructure into modules. Refactor your VPC networking into a standalone module that you could drop into any future Terraform project.

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. The EC2 instance in this project is covered under AWS Free Tier for eligible accounts, but if left running beyond free tier limits it will incur charges.

Cost warning

The t2.micro instance is free for 750 hours/month on accounts created before July 15, 2025. Accounts created after that date receive $100 in credits that cover t3.micro usage.

If your free tier has expired or your credits are exhausted, leaving the instance running will incur hourly charges. Choose Pause or Delete below if you are unsure about your free tier status.

Resources you used:

  • AWS infrastructure managed by Terraform (1 VPC, 1 subnet, 1 internet gateway, 1 route table, 1 route table association, 1 security group, 2 security group rules, 1 EC2 instance).
  • Local project directory terraform-web-server on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the web server accessible.

  • Your EC2 instance remains running and accessible at the output URL.
  • Monitor your free tier usage in the AWS Billing console under Free Tier to avoid surprise charges.
  • Your local terraform-web-server folder stays on your Desktop.

Pause - I'll come back to this later

Stop the EC2 instance to eliminate compute charges while keeping your Terraform files and state intact.

  • Find your instance ID by running:
  • Stop the instance by running the command below (replace the instance ID with your own from the output above):
terraform output instance_id
aws ec2 stop-instances --instance-ids [[INSTANCE_ID="your instance ID from the output above"]]
  • While stopped, you will not be charged for compute. Minimal EBS storage charges may apply (pennies per month).
  • To resume later, run terraform apply again from the terraform-web-server directory. Terraform will detect the stopped instance and bring it back to the desired state.

Delete - I don't want to use this again

Remove all AWS resources and delete local project files. This is the recommended option once you have finished the project.

  • Destroy all AWS resources by running:
terraform destroy
  • Type yes when prompted to confirm. Terraform will remove all 9 resources from your AWS account.
  • Verify the state is empty by running:
terraform state list

You should see no output, confirming all resources have been removed.

  • Delete the local project directory by running:
cd ~/Desktop
rm -rf terraform-web-server

Windows users

If you are on Windows, use this command instead to remove the project folder:

cd ~\Desktop
rmdir /s /q terraform-web-server

Nice Work!

Nice Work!

You just deployed a fully functional web server on AWS without clicking a single button in the console. Every resource was defined as code, versioned in Git, and reproducible on demand.

Here's what you learned:

  • Wrote Terraform configuration from scratch to define AWS infrastructure as code, including a VPC, subnet, internet gateway, and route table.
  • Deployed an EC2 web server accessible from the internet using modern security group rule resources.
  • Managed infrastructure through the full Terraform lifecycle: init, plan, apply, modify, and destroy.
  • Secret Mission: Refactored your VPC resources into a reusable Terraform module that you can drop into any future project.

Ready to quiz yourself?