AWS OpenTofu #110: EC2 Inside Your Own VPC
Unite networking, compute, and security. Provision an Amazon EC2 instance inside a custom multi-AZ VPC, manage it via SSM with zero open ports, and trace the complete end-to-end packet flow.
AWS + OpenTofu Level 100: Infrastructure as Code from Zero
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
In Lab 109, we constructed a production-grade, multi-AZ custom Virtual Private Cloud. We calculated CIDR blocks, partitioned public and private subnets, attached an Internet Gateway, and wired up custom route tables.
However, our custom network was an “empty highway”: pristine asphalt, painted lanes, and off-ramps, but zero vehicles driving on it.
Meanwhile, back in Lab 106 and Lab 107, we launched EC2 instances and managed them via AWS Systems Manager Session Manager—but we were still constrained to the default, flat AWS VPC.
Now, we bring everything together.
In this lab, you will unite Networking, Compute, and Identity into a single, cohesive infrastructure stack. You will deploy an EC2 web server directly into your custom public subnet, attach a hardened security group scoped to your custom VPC, inject an IAM instance profile for zero-trust SSM management, and trace the end-to-end packet flow from your local terminal to the Linux hypervisor.
🎯 What You Will Learn
| End-to-End AWS Networking | OpenTofu Dependency Graphs | Operational Verification |
|---|---|---|
| • Placing Elastic Network Interfaces (ENI) in subnets | • The 6-tier infrastructure dependency chain | • Auditing internal Linux routing (ip route) |
| • Scoping Security Groups to custom VPC IDs | • Inter-resource attribute referencing across files | • Testing HTTP ingress through the Internet Gateway |
| • How SSM communicates over custom VPC routes | • Clean multi-file modularization | • Validating SSM connectivity with zero open ports |
| • Packet traversal: Client ➔ IGW ➔ RT ➔ Subnet ➔ ENI | • Parallel vs. sequential resource provisioning | • Safe teardown of complex network/compute graphs |
💰 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) |
| Public IPv4 Address | Auto-assigned IPv4 (~1 hour) | $0.00 | $0.005 / hour (AWS IPv4 charge) |
| Custom VPC & Subnets | 1 VPC, 4 Subnets, 2 Route Tables | $0.00 | $0.00 (Always 100% Free) |
| Internet Gateway (IGW) | 1 Attached Gateway | $0.00 | $0.00 (Always 100% Free) |
| AWS Systems Manager (SSM) | Session Manager interactive access | $0.00 | $0.00 (Always 100% Free) |
| Total Estimated Cost | Complete Hands-On Session (~1 hour) | $0.00 | ~$0.019 (~2 cents total) |
[!NOTE] All networking primitives (VPC, Subnets, Route Tables, Internet Gateway) and SSM Session Manager are 100% free forever. If your AWS account has passed the 12-month Free Tier, your only cost will be the
t3.microcompute time during this lab (~2 cents).
🌐 Architecture: The End-to-End Packet Flow
Before writing code, let’s trace exactly how network packets traverse your custom infrastructure:
+───────────────────────────────────────────────────────────────────────────────────────────────────+
| THE END-TO-END PACKET PATH |
+───────────────────────────────────────────────────────────────────────────────────────────────────+
│
[ Public Internet User ] │ [ Engineer Terminal (SSM) ]
│ │ │
│ HTTP GET (Port 80) │ │ aws ssm start-session
▼ │ ▼
+─────────────────────────────────────────────────┼─────────────────────────────────────────────────+
| CUSTOM VPC (10.0.0.0/16) │ AWS SSM CONTROL PLANE |
| │ (ssm.ap-southeast-1.amazonaws.com) |
| +─────────────────────────────────────────+ │ │
| | 1. INTERNET GATEWAY (IGW) | │ │
| | Translates public IP to private ENI | │ │
| +─────────────────────────────────────────+ │ │
| │ │ │
| ▼ Destination: 0.0.0.0/0 │ │
| +─────────────────────────────────────────+ │ │
| | 2. PUBLIC ROUTE TABLE | │ │
| | Routes 10.0.0.0/16 locally | │ │
| | Routes 0.0.0.0/0 to IGW | │ │
| +─────────────────────────────────────────+ │ │
| │ │ │
| ▼ Subnet Routing │ │
| +─────────────────────────────────────────+ │ │
| | 3. PUBLIC SUBNET 1 (10.0.1.0/24 - AZ-a) | │ │
| +─────────────────────────────────────────+ │ │
| │ │ │
| ▼ Hypervisor Filtering │ │
| +─────────────────────────────────────────+ │ │
| | 4. SECURITY GROUP | │ │
| | - Ingress: TCP 80 (Allowed) | │ │
| | - Ingress: Port 22 (BLOCKED) | │ │
| | - Egress: All Traffic (Allowed) ────┼───┼────────────────────┘ (Outbound TLS 443)
| +─────────────────────────────────────────+ │
| │ │
| ▼ Virtual Interface │
| +─────────────────────────────────────────+ │
| | 5. EC2 INSTANCE (Private IP: 10.0.1.x) | │
| | - Nitro Hypervisor | │
| | - Encrypted gp3 Root EBS Volume | │
| | - IAM Instance Profile (SSM Core) | │
| | - Apache Web Service (cloud-init) | │
| +─────────────────────────────────────────+ │
+─────────────────────────────────────────────────┴─────────────────────────────────────────────────+
🧩 The 6-Tier OpenTofu Dependency Chain
Notice the deep dependency chain required to launch this server. OpenTofu must evaluate and provision resources in a precise mathematical order:
Tier 1: aws_vpc.main
│
├───────────────────────────────────┐
▼ ▼
Tier 2: aws_internet_gateway.gw aws_subnet.public_1
│ │
▼ │
Tier 3: aws_route_table.public │
│ │
▼ ▼
Tier 4: aws_route_table_association.public_1 aws_security_group.web aws_iam_role.ssm
│ │
│ ▼
Tier 5: │ aws_iam_instance_profile.ssm
│ │
▼ ▼
Tier 6: aws_instance.web_server ◄────┘
Because each resource references the attributes of its upstream parent (subnet_id = aws_subnet.public_1.id, vpc_security_group_ids = [aws_security_group.web.id]), OpenTofu automatically creates the Directed Acyclic Graph (DAG) and ensures no resource is created until its prerequisites exist.
📁 Step 1: Set Up Lab Directory Structure
Navigate to your ~/opentofu-labs repository and create the dedicated directory for Lab 110:
cd ~/opentofu-labs
mkdir -p lab-110-ec2-custom-vpc
cd lab-110-ec2-custom-vpc
We will organize our configuration into five clean, decoupled files:
lab-110-ec2-custom-vpc/
├── providers.tf # Engine & AWS Provider configuration
├── variables.tf # Parameterized inputs (region, CIDRs, instance type)
├── data.tf # Dynamic AMI & AZ discovery
├── vpc.tf # Custom VPC, Subnets, IGW & Routing
├── security.tf # Custom VPC Security Group
├── iam.tf # IAM Role, Policy Attachment & Instance Profile
├── compute.tf # EC2 Instance with User Data
└── outputs.tf # Public IP, Web URL & SSM connect command
⚙️ Step 2: Configure Providers, Variables & Data Sources
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 = "110"
}
}
}
Create variables.tf:
variable "aws_region" {
type = string
description = "Target AWS region."
default = "ap-southeast-1"
}
variable "environment" {
type = string
description = "Target deployment environment."
default = "dev"
}
variable "vpc_cidr" {
type = string
description = "CIDR block for the custom VPC."
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
type = string
description = "CIDR block for the public subnet."
default = "10.0.1.0/24"
}
variable "instance_type" {
type = string
description = "EC2 instance size."
default = "t3.micro"
}
Create data.tf:
# 1. Dynamically discover active Availability Zones
data "aws_availability_zones" "available" {
state = "available"
filter {
name = "opt-in-status"
values = ["opt-in-not-required"]
}
}
# 2. Dynamically look up the latest official Amazon Linux 2023 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"]
}
}
🌐 Step 3: Declare the Custom Network Backbone (vpc.tf)
Create vpc.tf to build our custom VPC, public subnet, Internet Gateway, and route table:
# 1. Custom VPC Container
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.environment}-custom-vpc"
}
}
# 2. Internet Gateway
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.environment}-igw"
}
}
# 3. Public Subnet placed in AZ-1
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true # Auto-assigns public IPv4
tags = {
Name = "${var.environment}-public-subnet-1"
Tier = "public"
}
}
# 4. Public Route Table routing 0.0.0.0/0 to the Internet Gateway
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
tags = {
Name = "${var.environment}-public-rt"
}
}
# 5. Associate Public Subnet with Public Route Table
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
🛡️ Step 4: Declare Firewall & Identity Security
1. Security Group (security.tf)
Notice that vpc_id points explicitly to aws_vpc.main.id. A Security Group created in one VPC cannot be attached to an instance in a different VPC!
Create security.tf:
resource "aws_security_group" "web" {
name = "${var.environment}-custom-vpc-web-sg"
description = "Allow inbound HTTP and all outbound egress"
vpc_id = aws_vpc.main.id
# Ingress: Allow HTTP port 80 for web traffic
ingress {
description = "Allow public HTTP traffic"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Ingress: SSH port 22 is deliberately OMITTED! (We manage via SSM)
# Egress: Allow outbound traffic (Required for SSM agent & OS package updates)
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.environment}-web-sg"
}
}
2. IAM Role & Instance Profile (iam.tf)
Create iam.tf to authorize Systems Manager Session Manager access:
# 1. IAM Role with EC2 AssumeRole Trust Policy
resource "aws_iam_role" "ec2_ssm" {
name = "${var.environment}-custom-vpc-ec2-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}
]
})
}
# 2. Attach AmazonSSMManagedInstanceCore
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.ec2_ssm.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
# 3. IAM Instance Profile container
resource "aws_iam_instance_profile" "ec2_ssm" {
name = "${var.environment}-custom-vpc-instance-profile"
role = aws_iam_role.ec2_ssm.name
}
🖥️ Step 5: Provision the EC2 Instance (compute.tf)
Now we connect the pieces. Notice line 6: subnet_id = aws_subnet.public_1.id. This instructs AWS to place the instance’s virtual network card directly inside our custom network.
Create compute.tf:
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.instance_type
# PLACEMENT: Attach directly into our custom public subnet!
subnet_id = aws_subnet.public_1.id
# FIREWALL: Attach the Security Group from our custom VPC
vpc_security_group_ids = [aws_security_group.web.id]
# IDENTITY: Attach the SSM Instance Profile
iam_instance_profile = aws_iam_instance_profile.ec2_ssm.name
# Encrypted Root EBS Block Device
root_block_device {
volume_type = "gp3"
volume_size = 20
encrypted = true
delete_on_termination = true
}
# Enforce IMDSv2
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
# Cloud-Init User Data: Installs Apache and reports custom network metadata
user_data = <<-EOF
#!/bin/bash
dnf update -y
dnf install -y httpd
systemctl start httpd
systemctl enable httpd
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=$(curl -s -H "x-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
LOCAL_IP=$(curl -s -H "x-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/local-ipv4)
cat <<HTML > /var/www/html/index.html
<!DOCTYPE html>
<html>
<head>
<title>OpenTofu Lab 110</title>
<style>
body { font-family: sans-serif; background: #0b0f19; color: #f1f5f9; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 2rem; width: 450px; text-align: center; }
h1 { color: #38bdf8; }
.stat { text-align: left; background: #0f172a; padding: 0.75rem 1rem; border-radius: 6px; margin: 0.5rem 0; font-family: monospace; }
.val { color: #4ade80; float: right; }
</style>
</head>
<body>
<div class="card">
<h1>🚀 Custom VPC Online</h1>
<p>EC2 Provisioned in Isolated Custom Subnet</p>
<div class="stat">VPC CIDR: <span class="val">10.0.0.0/16</span></div>
<div class="stat">Subnet IP: <span class="val">$LOCAL_IP</span></div>
<div class="stat">Instance ID: <span class="val">$INSTANCE_ID</span></div>
<div class="stat">Availability Zone: <span class="val">$AZ</span></div>
</div>
</body>
</html>
HTML
EOF
tags = {
Name = "${var.environment}-custom-vpc-web"
Tier = "PublicWeb"
}
}
📤 Step 6: Expose Verification Outputs (outputs.tf)
Create outputs.tf:
output "vpc_id" {
description = "The custom VPC ID."
value = aws_vpc.main.id
}
output "subnet_id" {
description = "The public subnet where the EC2 instance resides."
value = aws_subnet.public_1.id
}
output "instance_id" {
description = "The EC2 Instance ID."
value = aws_instance.web.id
}
output "private_ip" {
description = "The private IP allocated from our 10.0.1.0/24 CIDR."
value = aws_instance.web.private_ip
}
output "public_ip" {
description = "The public IPv4 assigned via our Internet Gateway."
value = aws_instance.web.public_ip
}
output "web_url" {
description = "Clickable HTTP URL to test the web service."
value = "http://${aws_instance.web.public_ip}"
}
output "ssm_connect_command" {
description = "CLI command to open an encrypted shell via SSM."
value = "aws ssm start-session --target ${aws_instance.web.id}"
}
🚀 Step 7: Preview & Provision (tofu apply)
Initialize the directory:
tofu init
tofu fmt
tofu validate
Preview the plan:
tofu plan
Notice the plan summary:
Plan: 9 to add, 0 to change, 0 to destroy.
+ aws_iam_instance_profile.ec2_ssm
+ aws_iam_role.ec2_ssm
+ aws_iam_role_policy_attachment.ssm_core
+ aws_instance.web
+ aws_internet_gateway.gw
+ aws_route_table.public
+ aws_route_table_association.public_1
+ aws_security_group.web
+ aws_subnet.public_1
+ aws_vpc.main
Apply the configuration:
tofu apply -auto-approve
aws_vpc.main: Creating...
aws_iam_role.ec2_ssm: Creating...
aws_iam_role.ec2_ssm: Creation complete after 1s
aws_iam_role_policy_attachment.ssm_core: Creating...
aws_iam_instance_profile.ec2_ssm: Creating...
aws_iam_instance_profile.ec2_ssm: Creation complete after 1s
aws_iam_role_policy_attachment.ssm_core: Creation complete after 1s
aws_vpc.main: Creation complete after 2s [id=vpc-0123456789abcdef0]
aws_internet_gateway.gw: Creating...
aws_subnet.public_1: Creating...
aws_security_group.web: Creating...
aws_internet_gateway.gw: Creation complete after 1s [id=igw-0123456789abcdef0]
aws_route_table.public: Creating...
aws_security_group.web: Creation complete after 1s [id=sg-0123456789abcdef0]
aws_subnet.public_1: Creation complete after 1s [id=subnet-0123456789abcdef0]
aws_route_table.public: Creation complete after 1s [id=rtb-0123456789abcdef0]
aws_route_table_association.public_1: Creating...
aws_route_table_association.public_1: Creation complete after 1s
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: 9 added, 0 changed, 0 destroyed.
Outputs:
instance_id = "i-0123456789abcdef0"
private_ip = "10.0.1.84"
public_ip = "13.250.88.190"
ssm_connect_command = "aws ssm start-session --target i-0123456789abcdef0"
subnet_id = "subnet-0123456789abcdef0"
vpc_id = "vpc-0123456789abcdef0"
web_url = "http://13.250.88.190"
Look at the private_ip output: 10.0.1.84.
The instance was assigned a private IP directly from your custom 10.0.1.0/24 subnet!
🧪 Step 8: Operational Verification & Linux Network Inspection
Wait 45 seconds for Apache to finish installing.
1. Test Web Reachability via curl
curl -s $(tofu output -raw web_url) | grep "Custom VPC Online"
Output:
<h1>🚀 Custom VPC Online</h1>
You can open the web_url in your browser. You will see the custom dark-mode card confirming the instance is running at 10.0.1.x inside your custom VPC.
2. Connect via SSM Session Manager
Open a shell using the output command:
$(tofu output -raw ssm_connect_command)
You will be dropped directly into the Linux terminal:
Starting session with SessionId: admin-prawira-0123456789abcdef0
sh-5.2$
3. Inspect Linux Network Routing Inside the Virtual Server
Inside your SSM session, let’s inspect how the operating system perceives the custom VPC:
# View assigned network interfaces and IP addresses
ip addr show dev eth0
Output:
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 9001 qdisc mq state UP group default qlen 1000
inet 10.0.1.84/24 brd 10.0.1.255 scope global dynamic eth0
valid_lft 3350sec preferred_lft 3350sec
The Linux kernel confirms its IP is 10.0.1.84 with netmask /24.
Now inspect the kernel routing table:
ip route show
Output:
default via 10.0.1.1 dev eth0 proto dhcp src 10.0.1.84 metric 512
10.0.1.0/24 dev eth0 proto kernel scope link src 10.0.1.84 metric 512
Look at default via 10.0.1.1:
The instance’s default gateway is 10.0.1.1—the AWS VPC Router IP we learned about in Lab 109! When packets leave this server destined for the internet, they hit 10.0.1.1, which evaluates your OpenTofu route table and sends the traffic out through the Internet Gateway.
Exit the SSM session:
exit
🧹 Step 9: Clean Teardown (tofu destroy)
Tear down all 9 resources:
tofu destroy -auto-approve
aws_instance.web: Destroying... [id=i-0123456789abcdef0]
aws_instance.web: Destruction complete after 30s
aws_security_group.web: Destroying...
aws_route_table_association.public_1: Destroying...
aws_iam_instance_profile.ec2_ssm: Destroying...
aws_iam_role_policy_attachment.ssm_core: Destroying...
aws_route_table.public: Destroying...
aws_subnet.public_1: Destroying...
aws_internet_gateway.gw: Destroying...
aws_iam_role.ec2_ssm: Destroying...
aws_vpc.main: Destroying...
Destroy complete! Resources: 9 destroyed.
OpenTofu terminates the EC2 instance first, frees the ENI, removes the security group, deletes the route associations, detaches the Internet Gateway, and destroys the VPC. Zero leftover resources.
🛠️ Common Troubleshooting & Gotchas
1. Error: SecurityGroupAndSubnetVpcMismatch
- Cause: Trying to attach a Security Group from the Default VPC to an instance running in your custom VPC (or vice versa).
- Rule: Every Security Group has a fixed
vpc_id. Instances can only attach Security Groups that belong to the identical VPC.
2. SSM Connect Fails with TargetNotConnected inside Custom VPC
- Cause: For SSM to work in a public subnet, the subnet must have an associated Route Table with a default route to the Internet Gateway (
0.0.0.0/0 -> igw), and the instance must have a public IP. If the route to the IGW is missing, the SSM agent cannot reach AWS endpoints.
3. Web Request Hangs on curl
- Check whether
map_public_ip_on_launch = truewas set on the subnet, or verify that the security groupingressallows port 80.
🏋️ Hands-On Challenge
Before moving to Lab 111:
- Deploy to AZ-2:
In
compute.tf, add a second EC2 instance (aws_instance.web_2) placed in a second public subnet in AZ-2 (availability_zone = data.aws_availability_zones.available.names[1]). - Run
tofu planto observe how OpenTofu expands your compute tier across multiple Availability Zones for high availability. - Verify that both instances receive independent private IPs from their respective subnet CIDRs.
📖 Summary & What You Learned
In this milestone lab, you united the three major pillars of cloud infrastructure:
- Network Placement: Launched an EC2 instance directly inside a custom public subnet (
10.0.1.0/24). - Scoped Firewalls: Created a dedicated
aws_security_groupanchored strictly toaws_vpc.main.id. - Packet Traversal: Traced packets from external clients through the Internet Gateway, Route Table, Subnet, and Security Group into the Linux kernel.
- Kernel Routing: Confirmed that the instance uses
10.0.1.1(the AWS VPC Router) as its default gateway. - Zero-Trust Management: Maintained our zero-open-port security posture, managing the custom VPC instance via SSM Session Manager.
🚀 What’s Next? (Lab 111)
Our web server currently runs in a public subnet. But what about our databases, backend workers, and private microservices?
Instances in private subnets cannot have public IPs and cannot talk to the Internet Gateway directly. But they still need to download Linux security updates and external API data.
In [AWS OpenTofu #111: Outbound Internet with NAT Gateways vs NAT Instances], we will:
- Explore the FinOps Dilemma: AWS managed NAT Gateways ($32/month) vs lightweight NAT Instances ($3/month).
- Route outbound traffic from private subnets through a NAT gateway.
- Preserve zero inbound connectivity while enabling secure outbound updates.