DevOpsLesson
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER

Terraform Tutorial

Introduction to Terraform
Installing Terraform
The Terraform Core Workflow
Terraform Variables and Outputs
Terraform State and Backends
Terraform Modules
Terraform Data Sources
Terraform Count and for_each
Terraform Expressions and Functions
Terraform Dynamic Blocks
Terraform Workspaces
Terraform with AWS
Terraform Provisioners
Terraform CI/CD Pipeline
Terraform Security

Terraform AWS EC2 Web Server

PreviousPrev
Next

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?

PreviousPrev
Next

Continue Learning

Terraform Workspaces

Learn what Terraform workspaces are, how workspace commands work, how state is separated, and when workspaces are helpful or the wrong environment strategy.

10 min read·Easy

Terraform with AWS

Build a complete AWS project with Terraform by combining a custom VPC, a public EC2 web server, and an S3 bucket for static assets.

5 min read·Medium

Terraform AWS VPC Setup

Build a custom AWS VPC with Terraform, including public and private subnets, an internet gateway, route tables, and a NAT gateway.

14 min read·Medium

Explore Related Topics

AW

AWS Tutorials

Provision AWS infrastructure with Terraform

CI

CI/CD Tutorials

Run terraform plan/apply in CI pipelines

Try the Tool

YAML Validator

Validate Terraform and Kubernetes YAML configs before applying.

On This Page

Continuing the AWS ProjectArchitecture for This LessonPrerequisitesReferencing the Existing VPC ResourcesCreating the Security GroupWhy separate SSH and HTTP rules?Finding the Latest Ubuntu AMI with a Data SourceWhat this doesCreating a Key Pair for SSH AccessA practical note about `~`Writing the User Data ScriptWhy user data mattersCreating the EC2 InstanceImportant arguments explainedAdding an Elastic IPWhy use an Elastic IP in a tutorial?Output Values for the InstanceRunning the Plan and ApplyTesting the DeploymentTest in the browserTest with SSHCommon Troubleshooting Tips1. SSH works but HTTP does not2. SSH does not work3. The instance starts but Nginx is missingComplete Working Code`main.tf``variables.tf``outputs.tf``terraform.tfvars`Final ThoughtsPractice Questions