Cloud 30 min read

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.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide β€’ Part 6 of 15

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:

  1. An engineer clicks Launch Instance.
  2. They select whatever AMI happens to be at the top of the list.
  3. 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).
  4. They leave the root volume unencrypted because the checkbox is tucked away inside an accordion menu.
  5. They SSH into the server, run manual sudo apt install or yum install commands, 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 PrimitivesOpenTofu Resource ArchitectureEngineering & 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

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
Amazon EC2 Virtual Machinet3.micro On-Demand (1 hour)$0.00~$0.012 / hour
Amazon EBS Storage20 GB gp3 Root Volume (1 hour)$0.00~$0.002 / hour ($0.08/GB-mo)
In-Use Public IPv4 AddressAuto-assigned Public IPv4 (1 hour)$0.00$0.005 / hour (AWS IPv4 charge)
Data Transfer & Security GroupInbound HTTP web test (<10 MB)$0.00$0.00 (Free inbound data)
Total Estimated CostComplete 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.micro instance with its 20 GB gp3 root disk and public IPv4 address costs approximately $0.019 per hour total. When you run tofu destroy after 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 ]
  1. OpenTofu queries the AWS API for the latest AMI ID and Default VPC ID.
  2. OpenTofu provisions the aws_security_group inside that VPC.
  3. 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:

  1. What AMI should we install? (Discovered dynamically, as learned in Lab 105).
  2. 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:

  1. 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).
  2. Mandatory Encryption: encrypted = true ensures the EBS volume is encrypted at rest with the regional default KMS key.
  3. 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: True validates our EBS storage security.
  • State: running confirms 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-init is 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 ingress port (must be from_port = 80, to_port = 80, protocol = "tcp").

2. Dependency Inversion Errors on Teardown

  • Symptom: tofu destroy fails with DependencyViolation: 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-run tofu destroy once the instance finishes terminating.

3. Accidentally Triggering Replacement on user_data Edits

  • By default, changing user_data in OpenTofu replaces the instance if user_data_replace_on_change = true is enabled. If you want to update the bootstrap script without destroying the server, leave user_data_replace_on_change = false (the default).

πŸ‹οΈ Hands-On Challenge

Before moving to Lab 107:

  1. Instance Type Scaling: In variables.tf, change instance_type default from t3.micro to t3.small.
  2. Run tofu plan. Notice how OpenTofu detects that instance_type can be updated in-place (~) rather than replacing the server!
  3. Revert it back to t3.micro to 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_group with 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 = true on the root gp3 EBS block device.
  • Automated Bootstrapping: Used user_data and cloud-init to 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!

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.