Terraform AWS EC2 Web Server

By Vishvesh Patel · DevOps EngineerUpdated July 12, 2026

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_data script 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.pub or ~/.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.main
  • aws_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 = true asks AWS for the latest matching image
  • owners = ["099720109477"] restricts results to Canonical, the publisher of official Ubuntu AMIs
  • the name filter 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_data script 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

  • ami chooses the Ubuntu image from the data source
  • instance_type controls CPU and memory
  • subnet_id places the instance in the first public subnet
  • vpc_security_group_ids attaches the firewall rules
  • key_name links the uploaded AWS key pair
  • associate_public_ip_address = true is fine for this lab, though you will also attach an Elastic IP next
  • user_data installs 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 ubuntu for 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

Exercise

Question 1: AMI Selection

Why is a Terraform `aws_ami` data source usually better than hardcoding an AMI ID?

Exercise

Question 2: User Data

What is the main benefit of using `user_data` in this EC2 tutorial?

Exercise

Question 3: Elastic IP

Why attach an Elastic IP to the EC2 instance in this project?


Frequently Asked Questions

How do I avoid hardcoding an AMI ID in Terraform?

Use the aws_ami data source with owner and name filters plus most_recent = true, or read AWS's published SSM public parameters with the aws_ssm_parameter data source, /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 resolves the current Amazon Linux image. Both remove a value that goes stale and becomes a security liability. Note that a new AMI then triggers instance replacement, so pair this with a launch template and an Auto Scaling group if you want control over when that happens.

Should I create EC2 instances directly or use a launch template?

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.

How do I pass startup configuration to an instance?

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.

Why does Terraform want to replace my instance when I only changed a tag?

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.

How do I attach an IAM role to an EC2 instance in Terraform?

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.

How do I manage EBS volumes with 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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Terraform Playground

Run init, plan, apply and destroy against simulated infrastructure.

When it breaks

Related Resources