What You'll Build
You will write Terraform configuration from scratch to provision a virtual machine on a cloud provider, along with the networking components it needs: a VPC, a public subnet, an internet gateway, and a security group. You will use the CricketPulse API server as the target use case — a t3.micro EC2 instance in AWS running Amazon Linux that will serve cricket scores. You will run `terraform init`, `terraform plan`, and `terraform apply` to provision the infrastructure, then modify a resource attribute and observe how `terraform plan` identifies the change, and finally run `terraform destroy` to clean up. This exercise builds the muscle memory for the core Terraform workflow that underlies all subsequent lessons.
Prerequisites
- An AWS account with IAM credentials having EC2, VPC, and security group permissions (or use a free-tier account — t3.micro qualifies for AWS Free Tier).
- Terraform CLI installed — `brew install terraform` on macOS, or download from developer.hashicorp.com/terraform/downloads.
- AWS CLI configured with credentials — `aws configure` with access key and secret, OR set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.
- A text editor and terminal — all Terraform operations happen in the terminal with .tf files edited in any editor.
- Understanding of Lessons 1–3 — IaC principles, declarative vs imperative, providers and resources.
Setup & Project Structure
Create a dedicated directory for the CricketPulse infrastructure and set up the Terraform configuration files. Using separate files for different concerns (providers, network, compute) is a common convention that keeps configurations readable.
# Create project directory
mkdir cricketpulse-infra && cd cricketpulse-infra
# Create file structure
touch main.tf network.tf compute.tf outputs.tf
# Set AWS credentials via environment variables
export AWS_ACCESS_KEY_ID="your-access-key"
export AWS_SECRET_ACCESS_KEY="your-secret-key"
export AWS_DEFAULT_REGION="us-east-1"
# Verify AWS access
aws sts get-caller-identity
# Should return your account ID, not an errorStep 1 — Foundation
Write the provider configuration and network resources — VPC, subnet, internet gateway, and route table. The network layer must exist before the compute layer, and Terraform will infer this order from the attribute references between resources.
# main.tf — provider configuration
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# network.tf — networking resources
resource "aws_vpc" "cricketpulse" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "cricketpulse-vpc", ManagedBy = "terraform" }
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.cricketpulse.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
map_public_ip_on_launch = true
tags = { Name = "cricketpulse-public-1a" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.cricketpulse.id
tags = { Name = "cricketpulse-igw" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.cricketpulse.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "cricketpulse-public-rt" }
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# Initialise and plan the network
# terraform init
# terraform planStep 2 — Core Logic
Add the security group and EC2 instance. The security group defines what network traffic the instance allows; the instance itself is the compute resource. Run `terraform apply` to provision everything and observe the dependency-ordered creation sequence.
# compute.tf — security group and EC2 instance
resource "aws_security_group" "api" {
name = "cricketpulse-api-sg"
description = "Security group for CricketPulse API server"
vpc_id = aws_vpc.cricketpulse.id
ingress {
description = "HTTP from internet"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH for administration"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"] # Restrict SSH to internal network only
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "cricketpulse-api-sg" }
}
# Look up the latest Amazon Linux 2023 AMI
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "cricketpulse_api" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.api.id]
user_data = <<-EOF
#!/bin/bash
yum update -y
yum install -y nodejs npm
echo '{"status":"CricketPulse API running"}' > /tmp/scores.json
python3 -m http.server 80 --directory /tmp &
EOF
tags = {
Name = "cricketpulse-api"
Environment = "dev"
ManagedBy = "terraform"
}
}
# outputs.tf
output "instance_public_ip" {
description = "Public IP of the CricketPulse API server"
value = aws_instance.cricketpulse_api.public_ip
}
# Apply!
# terraform apply
# (type 'yes' to confirm)
# After apply: note the instance public IP from outputsStep 3 — Integration & Enhancement
Modify the EC2 instance type (change `t3.micro` to `t3.small`) and run `terraform plan` to see how Terraform detects and presents the change. Observe the difference between in-place modifications and forced replacements (destroy + create).
# Modify instance type in compute.tf
# Change: instance_type = "t3.micro" → instance_type = "t3.small"
# Run terraform plan to see the change
terraform plan
# Output will show:
# ~ resource "aws_instance" "cricketpulse_api" {
# ~ instance_type = "t3.micro" -> "t3.small"
# # (forces replacement)
# - id = "i-0abc123..."
# + id = (known after apply)
# }
# Plan: 1 to add, 0 to change, 1 to destroy.
#
# Note: AWS requires stopping/recreating an instance to change its type
# Terraform shows this as a replacement (-/+) not an in-place update (~)
# Apply the change
terraform apply
# Check the state
terraform show # Shows full current state of all resources
terraform state list # Lists all managed resource addressesStep 4 — Testing & Verification
Verify the provisioned infrastructure works, explore the state file, and then destroy everything cleanly.
# Verify the instance is reachable
INSTANCE_IP=$(terraform output -raw instance_public_ip)
curl http://$INSTANCE_IP/scores.json
# {"status":"CricketPulse API running"}
# Explore the Terraform state
terraform state list
# aws_instance.cricketpulse_api
# aws_security_group.api
# aws_subnet.public
# aws_vpc.cricketpulse
# ...
# Show details of a specific resource in state
terraform state show aws_instance.cricketpulse_api
# Plan a destroy (see what will be removed)
terraform plan -destroy
# Plan: 0 to add, 0 to change, 6 to destroy.
# Destroy all managed resources
terraform destroy
# type 'yes' to confirm
# All resources destroyed — no orphaned AWS resources left behindWarning: EC2 instance type changes require instance replacement (stop, terminate, create new) — they are not in-place modifications. Any data stored on the instance's ephemeral disk is lost during replacement. If your instance has stateful data (application state, local files), always use persistent storage (EBS volumes, S3) and ensure your data is on attached volumes (which survive instance replacement) rather than the root volume alone. For this exercise, the instance is stateless, so replacement is safe. In production, understand which attributes cause replacement vs in-place update before applying changes.
Extension Challenge: Add an EBS data volume to the CricketPulse API instance and attach it using `aws_volume_attachment`. Notice that the volume is a separate Terraform resource from the instance — it can be replaced independently. Then add a second EC2 instance in a second availability zone (us-east-1b) using the same configuration but a different subnet. Finally, add an Application Load Balancer in front of both instances using `aws_lb`, `aws_lb_target_group`, and `aws_lb_listener` resources — making the CricketPulse API highly available.
- The complete Terraform workflow: `init` → `plan` → `apply` → modify code → `plan` → `apply` → `destroy`. Build this sequence into muscle memory.
- Data sources (`data "aws_ami" ...`) look up existing resources without owning them — use them for dynamic values like the latest AMI ID rather than hard-coding AMI IDs.
- Terraform plan output uses `+` (create), `~` (modify in-place), `-` (destroy), and `-/+` (destroy and recreate) — read the plan carefully before applying, especially `-/+` which implies data loss risk.
- All resources are automatically destroyed with their dependencies by `terraform destroy` in the correct order — no manual cleanup of orphaned resources needed.
- `terraform state list` and `terraform state show` are essential debugging tools — they show exactly what Terraform knows about each managed resource.
- Attribute changes that require resource replacement (like `instance_type` for EC2) are shown as `-/+` in the plan — always check whether a planned change causes replacement before applying to production.