Skip to main content

Hands-On: EC2 and a Security Group with Terraform

What We Are Building

A small but real Terraform project that provisions:

  • A security group allowing HTTP from anywhere and SSH from your IP only
  • A t3.micro EC2 instance running Nginx (installed via user data)
  • Outputs for the public IP so you can hit it in a browser

Everything stays inside the AWS free tier, and one command tears it all down at the end.

Step 1 — Install Terraform and Configure Credentials

# macOS
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform -version

Terraform reads AWS credentials the same way the AWS CLI does. If aws sts get-caller-identity works, Terraform will work:

aws configure # if not already set up
aws sts get-caller-identity

Use an IAM user or role with EC2 permissions — never root account keys.

Step 2 — Create the Project Structure

mkdir terraform-ec2-demo && cd terraform-ec2-demo
touch main.tf variables.tf outputs.tf terraform.tfvars

Keeping variables and outputs in separate files is a convention, not a requirement — Terraform loads every .tf file in the directory — but it keeps main.tf readable as the project grows.

Step 3 — Write variables.tf

variable "aws_region" {
type = string
description = "AWS region to deploy into"
default = "us-east-1"
}

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

variable "my_ip" {
type = string
description = "Your public IP in CIDR form, e.g. 203.0.113.7/32"
}

variable "project_name" {
type = string
default = "tf-demo"
}

my_ip has no default on purpose — locking SSH to a specific IP is a decision the person applying should make explicitly. Find yours with:

curl -s https://checkip.amazonaws.com

Then put it in terraform.tfvars:

my_ip = "203.0.113.7/32"

Step 4 — Write main.tf

terraform {
required_version = ">= 1.5"

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

provider "aws" {
region = var.aws_region
}

data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical

filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}

resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Allow HTTP from anywhere, SSH from my IP"

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 = [var.my_ip]
}

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

tags = {
Name = "${var.project_name}-web-sg"
}
}

resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id]

user_data = <<-EOF
#!/bin/bash
apt-get update -y
apt-get install -y nginx
echo "Provisioned by Terraform" > /var/www/html/index.html
EOF

tags = {
Name = "${var.project_name}-web"
}
}

The data "aws_ami" block is the important improvement over hardcoding an AMI ID: it looks up the latest Ubuntu 22.04 image at plan time, so the config works in any region and never references a deprecated AMI.

Step 5 — Write outputs.tf

output "instance_id" {
value = aws_instance.web.id
}

output "public_ip" {
value = aws_instance.web.public_ip
description = "Open http on this IP to see the Nginx page"
}

Step 6 — Init, Plan, Apply

terraform init
terraform fmt # normalizes formatting in place
terraform validate # catches syntax and reference errors
terraform plan

Read the plan output carefully. You should see exactly Plan: 2 to add, 0 to change, 0 to destroy — the security group and the instance. The AMI data source is a read, not a resource, so it does not count.

terraform apply
# type: yes

Apply takes about a minute. The outputs print at the end:

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

Outputs:

instance_id = "i-0abc123def456"
public_ip = "54.234.12.89"

Step 7 — Verify

curl http://54.234.12.89
# Provisioned by Terraform

Give it 1–2 minutes after apply — user data runs on first boot, and Nginx is not installed instantly. You can also confirm the resources exist from the CLI:

aws ec2 describe-instances --filters "Name=tag:Name,Values=tf-demo-web" \
--query "Reservations[].Instances[].State.Name"

Step 8 — Make a Change and Re-Apply

Change instance_type in terraform.tfvars to t3.small and run terraform plan. Terraform shows a ~ (in-place update) on the instance — resizing requires a stop/start, which Terraform handles. This diff-review-apply loop is the everyday Terraform workflow: edit files, plan, read, apply.

Step 9 — Destroy

terraform destroy
# type: yes

Destroy complete! Resources: 2 destroyed. — no orphaned security groups, no forgotten instances billing you at month end. This clean teardown is half the reason to define even throwaway experiments in Terraform instead of the console.

Common Problems

Error: configuring Terraform AWS Provider: no valid credential sources — Terraform cannot find credentials. Fix the AWS CLI setup first; Terraform inherits it.

UnauthorizedOperation on apply — the IAM identity lacks ec2:RunInstances or ec2:CreateSecurityGroup. Attach AmazonEC2FullAccess for this exercise.

Plan wants to replace the instance every run — usually a changed AMI (Canonical published a newer image). Pin the AMI ID in a variable if you need stability, at the cost of manual updates.