Terraform AWS EC2 Web Server
Continue the AWS Terraform project by launching an EC2 web server with a security group, key pair, user data, and Elastic IP.
Continuing the AWS Project
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:
- a security group that allows HTTP and SSH
- a data source that discovers the latest Ubuntu AMI
- an EC2 instance in a public subnet
- a
user_datascript that installs Nginx automatically - a key pair for SSH access
- an Elastic IP so the instance keeps a stable public address
- output values so you can quickly find the server after deployment
This 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.
Architecture for This Lesson
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
│
VPC
For 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.
Prerequisites
You should already have:
- the VPC resources from the previous lesson
- working AWS credentials
- Terraform installed
- an SSH public key on your machine, such as
~/.ssh/id_rsa.pubor~/.ssh/id_ed25519.pub
You 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.
Referencing the Existing VPC Resources
Because this lesson continues the previous tutorial, the example below assumes these resources already exist in the same Terraform project:
aws_vpc.mainaws_subnet.public
That 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].
Creating the Security Group
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"
}
}
Why separate SSH and HTTP rules?
Because they serve different purposes:
- SSH on port 22 is for administration
- HTTP on port 80 is for application traffic
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.
Finding the Latest Ubuntu AMI with a Data Source
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"]
}
}
What this does
most_recent = trueasks AWS for the latest matching imageowners = ["099720109477"]restricts results to Canonical, the publisher of official Ubuntu AMIs- the
namefilter matches the Ubuntu release pattern - the virtualization filter avoids odd matches that are not normal HVM images
This 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.
Creating a Key Pair for SSH Access
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"
}
A practical note about ~
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"
Writing the User Data Script
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
}
Why user data matters
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:
- Terraform provisions the server
- the
user_datascript performs lightweight first-boot configuration
For more advanced software configuration, tools like cloud-init, Packer, or Ansible are often cleaner than long user data scripts.
Creating the EC2 Instance
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"
}
}
Important arguments explained
amichooses the Ubuntu image from the data sourceinstance_typecontrols CPU and memorysubnet_idplaces the instance in the first public subnetvpc_security_group_idsattaches the firewall ruleskey_namelinks the uploaded AWS key pairassociate_public_ip_address = trueis fine for this lab, though you will also attach an Elastic IP nextuser_datainstalls Nginx automatically
Define 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.
Adding an Elastic IP
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
}
Why use an Elastic IP in a tutorial?
Because it makes testing easy:
- your browser bookmark stays valid
- your SSH command stays the same
- output values stay useful after small rebuilds
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.
Output Values for the Instance
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_dns
That is much faster than clicking through the AWS console looking for the instance.
Running the Plan and Apply
If you are adding these resources to the same project as the VPC lesson, run:
terraform init
terraform plan
In the plan, review:
- the selected AMI
- the security group ports
- the SSH CIDR range
- the subnet placement
- the user data presence
- the Elastic IP resource and association
Then apply:
terraform apply
Testing the Deployment
After apply completes, grab the Elastic IP:
terraform output web_public_ip
Test in the browser
Open:
http://<elastic-ip>
You should see the HTML page created by the user_data script.
Test with SSH
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 localhost
If Nginx is active and curl localhost returns the sample HTML, the bootstrap process worked.
Common Troubleshooting Tips
If the browser test fails, check these likely issues:
1. SSH works but HTTP does not
The most common cause is a security group mistake. Confirm port 80 is open to 0.0.0.0/0.
2. SSH does not work
Check:
- the private key matches the uploaded public key
- the username is
ubuntufor Ubuntu AMIs - your SSH CIDR allows your current public IP
- the instance really has the Elastic IP attached
3. The instance starts but Nginx is missing
Inspect cloud-init logs on the server:
sudo tail -n 100 /var/log/cloud-init-output.log
This file often reveals package install or script syntax errors.
Complete Working Code
Below is a complete working example that extends the earlier VPC project.
main.tf
terraform {
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.tf
variable "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.tf
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
}
terraform.tfvars
public_key_path = "/Users/yourname/.ssh/id_ed25519.pub"
ssh_allowed_cidr = "203.0.113.10/32"
instance_type = "t3.micro"
Final Thoughts
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.
Practice Questions
Question 1: AMI Selection
Why is a Terraform `aws_ami` data source usually better than hardcoding an AMI ID?
Question 2: User Data
What is the main benefit of using `user_data` in this EC2 tutorial?
Question 3: Elastic IP
Why attach an Elastic IP to the EC2 instance in this project?