In the previous lesson, you built the network layer: a custom VPC, public and private subnets, an internet gateway, route tables, and a NAT gateway. That network is the foundation. Now it is time to place a compute resource inside it.
In this lesson, you will launch a public EC2 web server with Terraform. The finished setup includes:
user_data script that installs Nginx automaticallyThis is a realistic next step because infrastructure projects rarely stop at âthe network exists.â A useful VPC normally hosts something: a web server, an application tier, a bastion host, or a load balancer.
You are continuing from the VPC project, so the EC2 instance will launch into one of the public subnets created earlier.
Internet
â
Elastic IP
â
EC2 instance (Nginx)
â
Security Group (80, 22 open)
â
Public subnet
â
VPCFor a learning project, a public EC2 instance is convenient because it is easy to test from your browser and from SSH. In a production system, you would usually place application instances in private subnets behind a load balancer, but starting with a public demo host keeps the lesson focused.
You should already have:
~/.ssh/id_rsa.pub or ~/.ssh/id_ed25519.pubYou also need a private key that matches the public key you upload. Terraform creates the AWS key pair resource from the public key. SSH later uses the private key on your machine.
Because this lesson continues the previous tutorial, the example below assumes these resources already exist in the same Terraform project:
aws_vpc.mainaws_subnet.publicThat means the EC2 resources can refer to them directly. If you later refactor your VPC into a separate module, the references would typically become module outputs such as module.network.vpc_id and module.network.public_subnet_ids[0].
The security group is the instance-level firewall. It decides which traffic is allowed in and out.
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Allow SSH and HTTP access to the web server"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_allowed_cidr]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
ManagedBy = "Terraform"
}
}Because they serve different purposes:
It is tempting in a lab to open SSH to 0.0.0.0/0, but that is not a good long-term habit. This tutorial uses a variable so you can scope SSH to your own IP or corporate network.
variable "ssh_allowed_cidr" {
description = "CIDR allowed to SSH into the EC2 instance"
type = string
default = "203.0.113.10/32"
}Replace that example IP with your real source CIDR before applying.
Hardcoding AMI IDs is a common beginner mistake. AMIs are region-specific and change over time. A data source solves that cleanly.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}most_recent = true asks AWS for the latest matching imageowners = ["099720109477"] restricts results to Canonical, the publisher of official Ubuntu AMIsname filter matches the Ubuntu release patternThis is far better than copying an AMI ID from a blog post. With the data source, the tutorial stays region-aware and less likely to go stale.
To SSH into the instance later, create an AWS key pair resource from your local public key file.
resource "aws_key_pair" "deployer" {
key_name = "${var.project_name}-key"
public_key = file(var.public_key_path)
tags = {
Name = "${var.project_name}-key"
ManagedBy = "Terraform"
}
}The important piece is file(var.public_key_path). The file() function reads a local file and returns its contents as a string.
Define the variable like this:
variable "public_key_path" {
description = "Path to the SSH public key file"
type = string
default = "~/.ssh/id_ed25519.pub"
}~Some shells expand ~, but Terraform itself does not always behave the way beginners expect with home-directory shortcuts. If you run into trouble, use the full absolute path instead.
For example:
public_key_path = "/Users/yourname/.ssh/id_ed25519.pub"user_data lets you bootstrap the instance when it starts. Instead of SSHing in manually and installing Nginx yourself, Terraform passes a startup script to cloud-init on the VM.
locals {
web_user_data = <<-EOF
#!/bin/bash
set -e
apt-get update -y
apt-get install -y nginx
cat > /var/www/html/index.html <<'HTML'
<html>
<head><title>Terraform AWS Web Server</title></head>
<body>
<h1>It works!</h1>
<p>This EC2 instance was provisioned by Terraform.</p>
</body>
</html>
HTML
systemctl enable nginx
systemctl restart nginx
EOF
}This pattern keeps the infrastructure reproducible. If the instance is destroyed and recreated, it configures itself the same way every time. That is much better than âlaunch a VM and click around until it works.â
It is also your first taste of the boundary between provisioning and configuration management:
user_data script performs lightweight first-boot configurationFor more advanced software configuration, tools like cloud-init, Packer, or Ansible are often cleaner than long user data scripts.
Now you can define the instance.
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.deployer.key_name
associate_public_ip_address = true
user_data = local.web_user_data
tags = {
Name = "${var.project_name}-web"
ManagedBy = "Terraform"
}
}ami chooses the Ubuntu image from the data sourceinstance_type controls CPU and memorysubnet_id places the instance in the first public subnetvpc_security_group_ids attaches the firewall ruleskey_name links the uploaded AWS key pairassociate_public_ip_address = true is fine for this lab, though you will also attach an Elastic IP nextuser_data installs Nginx automaticallyDefine the instance type variable:
variable "instance_type" {
description = "EC2 instance size"
type = string
default = "t3.micro"
}t3.micro is a reasonable default for a tutorial because it is cheap and capable enough for a demo web server.
AWS can assign a public IP automatically when the instance launches, but that address may change if the instance is recreated. An Elastic IP gives you a stable public address that survives instance replacement if you re-associate it.
resource "aws_eip" "web" {
domain = "vpc"
tags = {
Name = "${var.project_name}-web-eip"
ManagedBy = "Terraform"
}
}
resource "aws_eip_association" "web" {
instance_id = aws_instance.web.id
allocation_id = aws_eip.web.id
}Because it makes testing easy:
In production, many teams avoid direct EIPs on application instances and put a load balancer in front instead. But for a single demo server, an EIP is simple and practical.
Outputs make Terraform easier to consume after apply.
output "web_instance_id" {
description = "ID of the EC2 web instance"
value = aws_instance.web.id
}
output "web_public_ip" {
description = "Elastic IP address for the web server"
value = aws_eip.web.public_ip
}
output "web_public_dns" {
description = "Public DNS name of the EC2 instance"
value = aws_instance.web.public_dns
}Now after terraform apply, you can run:
terraform output web_public_ip
terraform output web_public_dnsThat is much faster than clicking through the AWS console looking for the instance.
If you are adding these resources to the same project as the VPC lesson, run:
terraform init
terraform planIn the plan, review:
Then apply:
terraform applyAfter apply completes, grab the Elastic IP:
terraform output web_public_ipOpen:
http://<elastic-ip>You should see the HTML page created by the user_data script.
Use the private key that matches the public key you uploaded:
ssh -i ~/.ssh/id_ed25519 ubuntu@<elastic-ip>Once connected, verify Nginx is running:
systemctl status nginx
curl localhostIf Nginx is active and curl localhost returns the sample HTML, the bootstrap process worked.
If the browser test fails, check these likely issues:
The most common cause is a security group mistake. Confirm port 80 is open to 0.0.0.0/0.
Check:
ubuntu for Ubuntu AMIsInspect cloud-init logs on the server:
sudo tail -n 100 /var/log/cloud-init-output.logThis file often reveals package install or script syntax errors.
Below is a complete working example that extends the earlier VPC project.
main.tfterraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
locals {
web_user_data = <<-EOF
#!/bin/bash
set -e
apt-get update -y
apt-get install -y nginx
cat > /var/www/html/index.html <<'HTML'
<html>
<head><title>Terraform AWS Web Server</title></head>
<body>
<h1>Terraform deployed this server</h1>
<p>Nginx was installed automatically with user_data.</p>
</body>
</html>
HTML
systemctl enable nginx
systemctl restart nginx
EOF
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
ManagedBy = "Terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
ManagedBy = "Terraform"
}
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-${count.index + 1}"
ManagedBy = "Terraform"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
ManagedBy = "Terraform"
}
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Allow SSH and HTTP access to the web server"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.ssh_allowed_cidr]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
ManagedBy = "Terraform"
}
}
resource "aws_key_pair" "deployer" {
key_name = "${var.project_name}-key"
public_key = file(var.public_key_path)
tags = {
Name = "${var.project_name}-key"
ManagedBy = "Terraform"
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.deployer.key_name
associate_public_ip_address = true
user_data = local.web_user_data
tags = {
Name = "${var.project_name}-web"
ManagedBy = "Terraform"
}
}
resource "aws_eip" "web" {
domain = "vpc"
tags = {
Name = "${var.project_name}-web-eip"
ManagedBy = "Terraform"
}
}
resource "aws_eip_association" "web" {
instance_id = aws_instance.web.id
allocation_id = aws_eip.web.id
}variables.tfvariable "aws_region" {
description = "AWS region for the infrastructure"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name used in resource tags"
type = string
default = "devopslesson"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "instance_type" {
description = "EC2 instance size"
type = string
default = "t3.micro"
}
variable "public_key_path" {
description = "Path to the SSH public key file"
type = string
}
variable "ssh_allowed_cidr" {
description = "CIDR allowed to SSH into the EC2 instance"
type = string
}outputs.tfoutput "web_instance_id" {
description = "ID of the EC2 web instance"
value = aws_instance.web.id
}
output "web_public_ip" {
description = "Elastic IP address for the web server"
value = aws_eip.web.public_ip
}
output "web_public_dns" {
description = "Public DNS name of the EC2 instance"
value = aws_instance.web.public_dns
}terraform.tfvarspublic_key_path = "/Users/yourname/.ssh/id_ed25519.pub"
ssh_allowed_cidr = "203.0.113.10/32"
instance_type = "t3.micro"This lesson shows how Terraform moves from ânetworking existsâ to âa real workload is running.â You used a data source to stay current with AMIs, a security group to control traffic, user data to automate first-boot configuration, and an Elastic IP to give the server a stable identity.
That is a strong baseline for many AWS labs. Next, you can add storage and static content to round out the project.
<MCQExercise
exercise={{
id: 'terraform-aws-ec2-q1',
title: 'Question 1: AMI Selection',
description: 'Why is a Terraform aws_ami data source usually better than hardcoding an AMI ID?',
options: [
{ id: 'a', text: 'Because hardcoded AMI IDs only work in the us-east-1 region forever' },
{ id: 'b', text: 'Because a data source can discover the latest matching image in a safer, more portable way' },
{ id: 'c', text: 'Because Terraform cannot launch EC2 instances from hardcoded AMI IDs' },
{ id: 'd', text: 'Because AWS requires all EC2 AMIs to come from a data source' },
],
correctId: 'b',
explanation: 'AMI IDs vary by region and change over time. Using the aws_ami data source with filters and most_recent = true helps keep the configuration current and less brittle.',
wrongExplanations: {
a: 'Hardcoded AMIs do work, but they become stale and region-specific. That is the real problem.',
c: 'Terraform can launch EC2 instances from a hardcoded AMI ID. It is just less maintainable.',
d: 'AWS does not require the use of Terraform data sources. This is a best-practice decision, not an AWS rule.',
},
}}
/>
<MCQExercise
exercise={{
id: 'terraform-aws-ec2-q2',
title: 'Question 2: User Data',
description: 'What is the main benefit of using user_data in this EC2 tutorial?',
options: [
{ id: 'a', text: 'It lets Terraform change the EC2 instance type without replacement' },
{ id: 'b', text: 'It stores SSH private keys inside the instance metadata service' },
{ id: 'c', text: 'It bootstraps the server automatically so Nginx is installed when the instance starts' },
{ id: 'd', text: 'It replaces the need for a security group' },
],
correctId: 'c',
explanation: 'The user_data script runs at first boot and installs Nginx automatically, making the server reproducible instead of relying on manual SSH steps after provisioning.',
wrongExplanations: {
a: 'user_data configures the instance at boot; it does not control instance resizing behavior.',
b: 'You should not store SSH private keys in instance metadata via user data.',
d: 'Security groups still control network access. User data has nothing to do with firewall rules.',
},
}}
/>
<MCQExercise exercise={{ id: 'terraform-aws-ec2-q3', title: 'Question 3: Elastic IP', description: 'Why attach an Elastic IP to the EC2 instance in this project?', options: [ { id: 'a', text: 'To give the instance a stable public address instead of a potentially changing auto-assigned one' }, { id: 'b', text: 'To make the instance eligible for private subnet placement' }, { id: 'c', text: 'To replace the need for a route table and internet gateway' }, { id: 'd', text: 'To encrypt traffic between the browser and Nginx automatically' }, ], correctId: 'a', explanation: 'An Elastic IP provides a fixed public IP that stays consistent across rebuilds and re-associations. That makes browser testing, DNS, and SSH simpler for a tutorial project.', wrongExplanations: { b: 'Elastic IPs are public addresses. They do not make a resource suitable for private subnet placement.', c: 'You still need correct VPC routing and an internet gateway for public connectivity.', d: 'Elastic IPs do not provide encryption. HTTPS requires TLS configuration, typically via a certificate and web server or load balancer setup.', }, }} />
Use a launch template with an Auto Scaling group for anything that matters, even at a desired capacity of one. A bare aws_instance is a pet: if it fails, nothing replaces it, and every change risks manual repair. A launch template plus an Auto Scaling group makes replacement routine and gives you rolling updates via instance refresh. Reserve standalone instances for genuinely one-off or experimental work.
Through user_data, rendered with the templatefile() function so values can be interpolated from Terraform. Use user_data_replace_on_change = true if you want a change to the script to replace the instance, since by default a modified user_data does not affect a running instance. It only executes on first boot. For anything substantial, bake the configuration into an AMI with Packer instead, which makes instances start ready and shortens boot time.
It usually does not, and a replacement means something else changed. Most often the AMI ID resolved by a data source, or an attribute that is immutable such as the availability zone, subnet or certain instance metadata options. Read the plan carefully: Terraform annotates the specific attribute with "forces replacement", which names the cause precisely. If it is an AMI refresh you did not intend, pin the AMI or add ignore_changes to the lifecycle block.
Create an aws_iam_role with a trust policy allowing ec2.amazonaws.com, attach the permission policies, then create an aws_iam_instance_profile referencing the role and set iam_instance_profile on the instance or launch template. The instance profile is the piece people miss. An EC2 instance cannot reference a role directly, and forgetting it produces credentials errors inside the instance that look unrelated to Terraform.
The root volume is configured inline with a root_block_device block; additional volumes are separate aws_ebs_volume resources attached with aws_volume_attachment. Use gp3 rather than gp2, since it is cheaper and faster with performance independent of size. Set encrypted = true, or better, enable EBS encryption by default at the account level so no configuration can forget it. Note that additional volumes do not delete on termination by default, which is usually what you want but does leave orphans if instances are replaced often.
Create an S3 bucket with Terraform: unique names with random_id, versioning, lifecycle rules, public access settings, a bucket policy and object uploads.
Learn how Terraform provisioners work: local-exec, remote-exec, file and connection blocks, null_resource, and why provisioners should be a last resort.
Build a Terraform pipeline in GitLab CI/CD with validate, plan, manual apply and destroy stages, GitLab-managed state and secure credential handling.