AWS OpenTofu #106: Launch Your First EC2 Instance
Step into cloud compute. Provision a hardened Amazon EC2 virtual machine with OpenTofu, configure virtual firewall security groups, encrypt root EBS storage, and bootstrap a live web server with user data.
AWS + OpenTofu Level 100: Infrastructure as Code from Zero
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
Welcome to Module 03: Compute & Identity Security!
Up to this point in our series, we have mastered cloud storage, variable parameterization, and dynamic data discovery. Now, we enter the beating heart of Amazon Web Services: virtual compute.
Consider how traditional teams launch virtual servers using ClickOps in the AWS Management Console:
- An engineer clicks Launch Instance.
- They select whatever AMI happens to be at the top of the list.
- For remote access, the wizard suggests creating an SSH key pair and automatically adds an inbound firewall rule: SSH Port 22 from
0.0.0.0/0(Anywhere). - They leave the root volume unencrypted because the checkbox is tucked away inside an accordion menu.
- They SSH into the server, run manual
sudo apt installoryum installcommands, and configure the application by hand.
Within 48 hours:
- Port scanners and botnets from around the globe hit port 22 with thousands of brute-force SSH attempts per minute.
- A security audit flags the unencrypted root volume as a non-compliant data leak risk.
- Two months later, the server crashes. Because the software was installed manually through an interactive SSH session, nobody knows how to recreate the server.
In this lab, we will break this cycle. You will provision an Amazon Elastic Compute Cloud (EC2) instance purely with declarative OpenTofu code. You will configure hypervisor-level firewall rules using Security Groups, encrypt your root Elastic Block Store (EBS) volume by default, bootstrap a live web application using automated cloud-init user data, and observe how OpenTofu builds and manages compute dependency graphs.
π― What You Will Learn
| AWS Compute & Storage Primitives | OpenTofu Resource Architecture | Engineering & Security Standards |
|---|---|---|
| β’ Amazon EC2 virtual machine architecture | β’ Declaring aws_instance & compute arguments | β’ Zero-trust firewall rules (No open SSH port 22) |
| β’ Security Groups as stateful hypervisor firewalls | β’ Security Group rules decomposition | β’ Mandatory root volume encryption (encrypted = true) |
| β’ EBS storage types (gp3 vs gp2) & lifecycle | β’ Bootstrapping servers via user_data | β’ Cost control using Free Tier t3.micro |
| β’ Default VPC & Subnet discovery | β’ Inter-resource attribute referencing | β’ Verifying live services via curl & aws ec2 |
π° Estimated Lab Cost
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Amazon EC2 Virtual Machine | t3.micro On-Demand (1 hour) | $0.00 | ~$0.012 / hour |
| Amazon EBS Storage | 20 GB gp3 Root Volume (1 hour) | $0.00 | ~$0.002 / hour ($0.08/GB-mo) |
| In-Use Public IPv4 Address | Auto-assigned Public IPv4 (1 hour) | $0.00 | $0.005 / hour (AWS IPv4 charge) |
| Data Transfer & Security Group | Inbound HTTP web test (<10 MB) | $0.00 | $0.00 (Free inbound data) |
| Total Estimated Cost | Complete Hands-On Session (~1 hour) | $0.00 | ~$0.02 (approx. 2 cents) |
[!NOTE] No Free Tier? Expect ~2 Cents: If your AWS account has passed the 12-month Free Tier period, running this
t3.microinstance with its 20 GB gp3 root disk and public IPv4 address costs approximately $0.019 per hour total. When you runtofu destroyafter completing the verification steps, your actual credit card charge will be roughly two cents ($0.02).
π₯οΈ Architecture: Anatomy of a Cloud Virtual Server
Before writing code, letβs understand the layers that make up an EC2 instance:
+-------------------------------------------------------------------------------+
| AWS REGION (e.g. ap-southeast-1) / DEFAULT VPC |
| |
| +-----------------------------------------------------------------------+ |
| | SECURITY GROUP (Stateful Virtual Firewall) | |
| | - INGRESS: TCP 80 (HTTP) from 0.0.0.0/0 | |
| | - INGRESS: Port 22 (SSH) => BLOCKED (Zero-Trust) | |
| | - EGRESS: All Traffic Allowed (Outbound updates & packages) | |
| | | |
| | +---------------------------------------------------------------+ | |
| | | EC2 INSTANCE: "tofu-web-dev" (t3.micro) | | |
| | | | | |
| | | [ AMI: Amazon Linux 2023 x86_64 ] (Discovered dynamically) | | |
| | | | | |
| | | [ User Data Script ] | | |
| | | ββ Installs Nginx / Apache on initial boot | | |
| | | ββ Generates custom HTML welcome page | | |
| | | | | |
| | | [ Root EBS Block Device ] | | |
| | | ββ Volume Size: 20 GB (gp3) | | |
| | | ββ Encryption: Encrypted at Rest (AES-256) | | |
| | | ββ Delete on Termination: true | | |
| | | | | |
| | | [ Elastic Network Interface (ENI) ] | | |
| | | ββ Private IP: 172.31.x.x (VPC internal) | | |
| | | ββ Public IPv4: Auto-assigned public IP | | |
| | +---------------------------------------------------------------+ | |
| +-----------------------------------------------------------------------+ |
+-------------------------------------------------------------------------------+
1. The Hypervisor (AWS Nitro System)
EC2 instances run on physical servers managed by the AWS Nitro System, a purpose-built hardware and hypervisor architecture that offloads virtualization functions (networking, storage, security) to dedicated hardware cards, delivering near bare-metal performance.
2. Security Groups (Stateful Virtual Firewalls)
A Security Group acts as a virtual firewall operating at the hypervisor layer before traffic ever reaches the operating system.
- Stateful: If you allow an inbound request on port 80, the outbound response is automatically allowed back out, regardless of outbound rules.
- Default Deny: All inbound traffic is blocked unless explicitly permitted by an ingress rule.
3. Elastic Block Store (EBS)
The virtual hard disk attached to your virtual machine.
- We configure gp3 (General Purpose SSD), which delivers baseline performance of 3,000 IOPS and 125 MB/s throughput independently of storage capacity.
- We enforce encryption at rest by setting
encrypted = true.
π The OpenTofu Resource Graph (DAG)
Look at how OpenTofu coordinates the creation order:
data.aws_ami.amazon_linux_2023 (From Lab 105)
β
data.aws_vpc.default β
β βΌ
βΌ aws_security_group.web_server
aws_instance.web_server βββββββ
β
βΌ
[ Outputs: Public IP & Web URL ]
- OpenTofu queries the AWS API for the latest AMI ID and Default VPC ID.
- OpenTofu provisions the
aws_security_groupinside that VPC. - OpenTofu provisions
aws_instance, injecting the security group ID, the discovered AMI ID, and the cloud-init bootstrap script.
π Step 1: Set Up Lab Directory
Navigate to your ~/opentofu-labs repository and create the directory for Lab 106:
cd ~/opentofu-labs
mkdir -p lab-106-ec2-basics
cd lab-106-ec2-basics
We will structure our code cleanly across the standard files:
lab-106-ec2-basics/
βββ providers.tf # OpenTofu & AWS Provider configuration
βββ variables.tf # Parameterized instance type & environment
βββ data.tf # Discovers latest AMI & default VPC
βββ user-data.sh # Cloud-init server bootstrap script
βββ main.tf # Security Group & EC2 Instance resources
βββ outputs.tf # Public IP, Instance ID & Web endpoint URL
βοΈ Step 2: Configure Providers & Variables
Create providers.tf:
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "OpenTofu-Level-100"
Environment = var.environment
ManagedBy = "OpenTofu"
LabPart = "106"
}
}
}
Create variables.tf:
variable "aws_region" {
type = string
description = "Target AWS region."
default = "ap-southeast-1"
}
variable "environment" {
type = string
description = "Deployment environment."
default = "dev"
}
variable "instance_type" {
type = string
description = "EC2 instance family and size (Free Tier eligible)."
default = "t3.micro"
}
variable "server_name" {
type = string
description = "Descriptive tag name for the EC2 server."
default = "tofu-web-demo"
}
π Step 3: Discover AMI & Default VPC (data.tf)
Before launching an instance, we need two pieces of information:
- What AMI should we install? (Discovered dynamically, as learned in Lab 105).
- What VPC should the security group attach to? (We query the pre-existing AWS Default VPC).
Create data.tf:
# 1. Dynamically fetch the latest official Amazon Linux 2023 x86_64 AMI
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-kernel-6.1-x86_64"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# 2. Discover the AWS Default VPC present in every standard account
data "aws_vpc" "default" {
default = true
}
π Step 4: Write the Bootstrap Script (user-data.sh)
Instead of logging into servers manually to install packages, cloud engineers use User Data.
When an EC2 instance launches for the first time, the cloud-init service executes this shell script as root.
Create user-data.sh:
#!/bin/bash
# Update installed packages
dnf update -y
# Install Apache HTTP Server
dnf install -y httpd
# Start and enable the web server daemon
systemctl start httpd
systemctl enable httpd
# Discover local instance metadata (Token-based IMDSv2)
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "x-aws-ec2-metadata-token-ttl-seconds: 60")
INSTANCE_ID=$(curl -s -H "x-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
AZ_NAME=$(curl -s -H "x-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
# Generate a styled HTML welcome page displaying real instance metadata
cat <<HTML > /var/www/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OpenTofu EC2 Lab 106</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #f8fafc; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 2rem 3rem; box-shadow: 0 10px 25px rgba(0,0,0,0.5); text-align: center; max-width: 500px; }
h1 { color: #38bdf8; margin-bottom: 0.5rem; }
.badge { display: inline-block; background: #0284c7; color: white; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.875rem; font-weight: 600; margin-bottom: 1.5rem; }
.meta-box { background: #0f172a; border-radius: 8px; padding: 1rem; text-align: left; font-family: monospace; font-size: 0.9rem; line-height: 1.6; }
.meta-key { color: #94a3b8; }
.meta-val { color: #4ade80; }
</style>
</head>
<body>
<div class="card">
<h1>π OpenTofu Level 100</h1>
<div class="badge">Lab 106: EC2 Instance Provisioned</div>
<div class="meta-box">
<div><span class="meta-key">Status:</span> <span class="meta-val">Online & Healthy</span></div>
<div><span class="meta-key">Instance ID:</span> <span class="meta-val">$INSTANCE_ID</span></div>
<div><span class="meta-key">Availability Zone:</span> <span class="meta-val">$AZ_NAME</span></div>
<div><span class="meta-key">Managed By:</span> <span class="meta-val">OpenTofu Engine</span></div>
</div>
</div>
</body>
</html>
HTML
π‘οΈ Step 5: Declare Infrastructure (main.tf)
Now, create main.tf. Notice how we link the security group directly to the EC2 instance:
# 1. Security Group: Stateful Hypervisor Firewall
resource "aws_security_group" "web" {
name = "${var.server_name}-sg"
description = "Allow inbound HTTP traffic and unrestricted egress"
vpc_id = data.aws_vpc.default.id
# Inbound Rule: Allow HTTP (TCP Port 80) from anywhere
ingress {
description = "Allow public HTTP web requests"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Outbound Rule: Allow all egress traffic so server can download packages/updates
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1" # -1 means all protocols
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.server_name}-sg"
}
}
# 2. EC2 Virtual Machine Instance
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.instance_type
# Attach the Security Group declared above
vpc_security_group_ids = [aws_security_group.web.id]
# Inject the cloud-init bootstrap script
user_data = file("${path.module}/user-data.sh")
# Hardened Root EBS Volume Configuration
root_block_device {
volume_type = "gp3"
volume_size = 20 # 20 GB (Free Tier allows up to 30 GB)
encrypted = true
delete_on_termination = true
tags = {
Name = "${var.server_name}-root-ebs"
}
}
# Enforce IMDSv2 (Blocks SSRF credential theft vulnerabilities)
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # Rejects insecure legacy IMDSv1
http_put_response_hop_limit = 1
}
tags = {
Name = var.server_name
Role = "WebServer"
}
}
Security Highlights in This Code:
- No Port 22 SSH Ingress: We do not open port 22 to
0.0.0.0/0. In Lab 107, we will demonstrate how to manage this server securely without open inbound ports using AWS Systems Manager (SSM). - Mandatory Encryption:
encrypted = trueensures the EBS volume is encrypted at rest with the regional default KMS key. - IMDSv2 Enforced:
http_tokens = "required"protects against Server-Side Request Forgery (SSRF) vulnerabilities that led to famous breaches like the Capital One incident.
π€ Step 6: Expose Server Metadata (outputs.tf)
Create outputs.tf:
output "instance_id" {
description = "The unique EC2 Instance ID."
value = aws_instance.web.id
}
output "public_ip" {
description = "The public IPv4 address assigned to the server."
value = aws_instance.web.public_ip
}
output "web_url" {
description = "The clickable HTTP web URL to test your server in a browser."
value = "http://${aws_instance.web.public_ip}"
}
output "ami_id_used" {
description = "The dynamic AMI ID resolved by OpenTofu."
value = aws_instance.web.ami
}
output "root_volume_encrypted" {
description = "Confirmation of root volume encryption status."
value = aws_instance.web.root_block_device[0].encrypted
}
π Step 7: Preview Changes with tofu plan
Initialize the directory:
tofu init
Format and validate your HCL:
tofu fmt
tofu validate
Preview the execution plan:
tofu plan
Examine the plan output:
OpenTofu will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0123456789abcdef0"
+ arn = (known after apply)
+ instance_type = "t3.micro"
+ public_ip = (known after apply)
+ vpc_security_group_ids = (known after apply)
...
+ root_block_device {
+ delete_on_termination = true
+ encrypted = true
+ volume_size = 20
+ volume_type = "gp3"
}
}
# aws_security_group.web will be created
+ resource "aws_security_group" "web" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "tofu-web-demo-sg"
+ ingress = [ ... ]
+ egress = [ ... ]
}
Plan: 2 to add, 0 to change, 0 to destroy.
Notice that OpenTofu resolved the AMI dynamically, knows it must create the security group before the instance, and plans 2 additions.
β‘ Step 8: Provision the Server (tofu apply)
Execute the plan:
tofu apply
Type yes when prompted.
aws_security_group.web: Creating...
aws_security_group.web: Creation complete after 2s [id=sg-0abc123456789def0]
aws_instance.web: Creating...
aws_instance.web: Still creating... [10s elapsed]
aws_instance.web: Still creating... [20s elapsed]
aws_instance.web: Creation complete after 25s [id=i-0123456789abcdef0]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
ami_id_used = "ami-0123456789abcdef0"
instance_id = "i-0123456789abcdef0"
public_ip = "13.250.45.120"
root_volume_encrypted = true
web_url = "http://13.250.45.120"
In 25 seconds, OpenTofu provisioned a physical slice of hardware in Singapore, created the network firewall, and started the instance boot cycle.
π§ͺ Step 9: Live Verification from the Terminal
Allow approximately 30 to 60 seconds for cloud-init to finish package installation and start Apache.
1. Test the Live Web Endpoint
Use curl with the dynamic output:
curl -s $(tofu output -raw web_url) | grep "OpenTofu Level 100"
Output:
<h1>π OpenTofu Level 100</h1>
You can also copy the web_url from your outputs (e.g. http://13.250.45.120) and open it in your web browser! You will see the styled dark-mode card displaying your instance ID and Availability Zone.
2. Verify Instance State via AWS CLI
Confirm that the instance is running and healthy:
INSTANCE_ID=$(tofu output -raw instance_id)
aws ec2 describe-instances \
--instance-ids $INSTANCE_ID \
--query "Reservations[0].Instances[0].{State:State.Name, Type:InstanceType, PublicIp:PublicIpAddress, Encrypted:BlockDeviceMappings[0].Ebs.Encrypted}" \
--output table
Output:
-------------------------------------------------------------
| DescribeInstances |
+-----------+-----------------------+----------+------------+
| Encrypted | PublicIp | State | Type |
+-----------+-----------------------+----------+------------+
| True | 13.250.45.120 | running | t3.micro |
+-----------+-----------------------+----------+------------+
Notice:
Encrypted: Truevalidates our EBS storage security.State: runningconfirms the virtual machine is online.
π Step 10: Understanding Instance Replacement Triggers
In OpenTofu, some changes can be applied in-place (like modifying a tag or security group description), while others force a complete destruction and recreation of the server.
For example, if you change:
instance_type(e.g.,t3.micro->t3.small): Can be modified with a simple stop/start in-place.ami: Forces replacement (-+). An existing virtual machine cannot change its underlying boot image without creating a new virtual machine.
You can control this behavior using OpenTofu lifecycle blocks:
lifecycle {
create_before_destroy = true
}
This guarantees that a new replacement instance is fully provisioned before the old instance is terminated, minimizing downtime.
π§Ή Step 11: Clean Teardown (tofu destroy)
Never leave unneeded compute resources running in your cloud account:
tofu destroy
Type yes when prompted:
aws_instance.web: Destroying... [id=i-0123456789abcdef0]
aws_instance.web: Still destroying... [10s elapsed]
aws_instance.web: Destruction complete after 30s
aws_security_group.web: Destroying... [id=sg-0abc123456789def0]
aws_security_group.web: Destruction complete after 1s
Destroy complete! Resources: 2 destroyed.
OpenTofu gracefully terminates the EC2 instance, deletes the attached 20 GB root EBS volume, and removes the security group in reverse dependency order.
Verify termination:
aws ec2 describe-instances \
--instance-ids $INSTANCE_ID \
--query "Reservations[0].Instances[0].State.Name" \
--output text
# Output: terminated (or shutting-down)
π οΈ Common Troubleshooting & Gotchas
1. Web Page Returns Connection Refused or Times Out
- Symptom: Running
curl http://<public-ip>hangs or returns connection refused. - Causes:
cloud-initis still executing package downloads (wait 45 seconds and retry).- You are on a corporate VPN that blocks outbound port 80 traffic.
- Typo in your security group
ingressport (must befrom_port = 80, to_port = 80, protocol = "tcp").
2. Dependency Inversion Errors on Teardown
- Symptom:
tofu destroyfails withDependencyViolation: resource sg-xxx has a dependent object. - Cause: Trying to delete a Security Group while an EC2 network interface is still detaching.
- Fix: OpenTofu manages this automatically when you use
vpc_security_group_ids = [aws_security_group.web.id]. If you ever get stuck, re-runtofu destroyonce the instance finishes terminating.
3. Accidentally Triggering Replacement on user_data Edits
- By default, changing
user_datain OpenTofu replaces the instance ifuser_data_replace_on_change = trueis enabled. If you want to update the bootstrap script without destroying the server, leaveuser_data_replace_on_change = false(the default).
ποΈ Hands-On Challenge
Before moving to Lab 107:
- Instance Type Scaling:
In
variables.tf, changeinstance_typedefault fromt3.microtot3.small. - Run
tofu plan. Notice how OpenTofu detects thatinstance_typecan be updated in-place (~) rather than replacing the server! - Revert it back to
t3.microto remain strictly within the Free Tier.
π Summary & What You Learned
In this lab, you built your first complete cloud compute infrastructure with zero ClickOps:
- EC2 Primitives: Understood the Nitro hypervisor architecture, instance sizing, and virtualization types.
- Stateful Firewalls: Declared
aws_security_groupwith explicit ingress and egress boundaries. - Zero-Trust Networking: Kept port 22 SSH closed to the public internet, avoiding brute-force botnet attacks.
- Storage Encryption: Enforced
encrypted = trueon the root gp3 EBS block device. - Automated Bootstrapping: Used
user_dataandcloud-initto install packages and configure an Apache web service on initial boot. - Metadata Security: Enforced IMDSv2 (
http_tokens = "required") to protect server credentials against SSRF vulnerabilities.
π Whatβs Next? (Lab 107)
Notice a glaring question: If port 22 SSH is closed, how do we securely log into our server to troubleshoot, inspect logs, or execute commands?
In the past, engineers set up expensive Bastion hosts or opened SSH keys. Today, enterprise cloud engineers use AWS Systems Manager (SSM) Session Manager.
In [AWS OpenTofu #107: Secure EC2 Management with IAM & SSM], we will:
- Eliminate SSH keys, key pairs, and bastion hosts completely.
- Build an IAM Role and Instance Profile with least privilege (
AmazonSSMManagedInstanceCore). - Connect to our private server shell directly from the terminal over encrypted AWS control plane APIs with zero open inbound ports!