AWS OpenTofu #114: Build a Mini AWS Environment
Assemble VPC, multi-AZ subnets, EC2 web servers, IAM least-privilege, SSM access, and S3 into a complete modular environment with variables, locals, outputs, and validation.
AWS + OpenTofu Level 100: Infrastructure as Code from Zero
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
Over the previous thirteen labs, you built individual cloud primitives in isolation:
- In Lab 103, you configured hardened S3 buckets with encryption and public access blocks.
- In Lab 104, you mastered variables, locals, custom validation, and outputs.
- In Lab 105, you dynamically discovered AMIs and Availability Zones using data sources.
- In Lab 106 & 107, you provisioned EC2 instances with encrypted EBS and zero-trust SSM management.
- In Lab 108, you constructed least-privilege IAM policies using
data.aws_iam_policy_document. - In Lab 109 & 110, you built custom multi-AZ VPCs, subnets, route tables, and placed compute inside them.
- In Lab 111, you diagnosed network failures, ephemeral port traps, and security group chaining.
- In Lab 112 & 113, you mastered state files, refactoring, drift detection, and modern declarative imports.
Now, we take off the training wheels.
In this first capstone project, you will step into the role of a Lead Cloud Platform Architect. Your organization needs a complete, resilient, and production-grade Mini AWS Environment. You will assemble every single Level 100 concept into a single, cohesive, multi-file OpenTofu codebase that deploys clean infrastructure from scratch.
π― What You Will Build
AWS REGION (us-east-1)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CUSTOM VPC: 10.0.0.0/16 β
β β
β Internet Gateway (0.0.0.0/0 Route) β
β β β
β ββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββ β
β βΌ βΌ β
β ββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ β
β β Public Subnet 1 (AZ-A) β β Public Subnet 2 (AZ-B) β β
β β CIDR: 10.0.1.0/24 β β CIDR: 10.0.2.0/24 β β
β β β β β β
β β ββββββββββββββββββββββββββ β β ββββββββββββββββββββββββββ β β
β β β EC2 Web Server A β β β β EC2 Web Server B β β β
β β β β’ IMDSv2 Hardened β β β β β’ IMDSv2 Hardened β β β
β β β β’ Encrypted gp3 Root β β β β β’ Encrypted gp3 Root β β β
β β β β’ Nginx Web Daemon β β β β β’ Nginx Web Daemon β β β
β β β β’ SSM Session Manager β β β β β’ SSM Session Manager β β β
β β βββββββββββββ¬βββββββββββββ β β βββββββββββββ¬βββββββββββββ β β
β ββββββββββββββββΌββββββββββββββββ ββββββββββββββββΌββββββββββββββββ β
β β β β
β ββββββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββ β
β βΌ β
β ββββββββββββββββββββββββββββββββββββ β
β β IAM Role + Least-Privilege Policyβ β
β β β’ Full SSM Session Manager Accessβ β
β β β’ Scoped S3 App Bucket Read/Writeβ β
β ββββββββββββββββββ¬ββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββββββββ
β Secure S3 Application Data Bucket β
β β’ Versioning Enabled β
β β’ AES-256 Server-Side Encryption β
β β’ Public Access Blocked (100%) β
β β’ TLS/HTTPS-Only Bucket Policy Enforcedβ
ββββββββββββββββββββββββββββββββββββββββββ
π° Estimated Lab Cost
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Amazon EC2 Virtual Machines | 2x t3.micro On-Demand (~1 hour) | $0.00 | ~$0.024 / hour |
| Amazon EBS Storage | 2x 20 GB gp3 Root Volumes (~1 hour) | $0.00 | ~$0.004 / hour |
| Public IPv4 Addresses | 2x Auto-assigned IPv4 (~1 hour) | $0.00 | $0.010 / hour ($0.005/ea) |
| Custom VPC & Subnets | 1 VPC, 4 Subnets, 2 Route Tables, 1 IGW | $0.00 | $0.00 (Always 100% Free) |
| Amazon S3 Storage | 1 Secured Storage Bucket | $0.00 | $0.00 (Negligible < $0.001) |
| AWS Systems Manager (SSM) | Zero-trust interactive console access | $0.00 | $0.00 (Always 100% Free) |
| Total Estimated Cost | Complete Capstone Run (~1 hour) | $0.00 | ~$0.038 (~4 cents total) |
[!NOTE] All networking, IAM policies, S3 security defaults, and SSM Session Manager are 100% free forever. If your account is past its first 12-month Free Tier, your only cost will be running two
t3.microinstances for the duration of this lab (~4 cents total). We will cleanly destroy everything at the end.
ποΈ Architecture Blueprint & Standards
To ensure production-grade quality, our mini environment adheres strictly to enterprise standards:
- Multi-AZ Availability:
Workloads must survive an individual AWS data center failure. We partition our network across two dynamic Availability Zones (
us-east-1aandus-east-1b) and deploy redundant EC2 web instances. - Network Segmentation:
- Public Subnets: Host web-facing compute with routes to the Internet Gateway.
- Private Subnets: Reserved for databases and internal microservices with no direct route to the Internet Gateway.
- Zero-Trust Compute Security:
- Zero Inbound Port 22 SSH: All administration is conducted strictly over out-of-band AWS Systems Manager (SSM) Session Manager.
- Stateful Security Groups: Web ingress is restricted to HTTP (port 80).
- IMDSv2 Enforced: Instance Metadata Service token requirement (
http_tokens = "required") prevents SSRF credential extraction. - Encrypted Storage: Root EBS volumes are encrypted using AWS-managed KMS keys.
- Least-Privilege Identity: EC2 instances are granted an IAM role allowing access only to SSM Session Manager and our specific S3 application bucket. All other AWS services remain inaccessible.
- Data Protection at Rest & in Transit:
Our S3 storage bucket enforces server-side encryption (SSE-S3), object versioning, public access blocks, and an explicit TLS-only (
aws:SecureTransport) bucket policy.
ποΈ Step 1: Project Setup & Workspace Structure
Create the project directory:
mkdir -p ~/opentofu-labs/lab-114-capstone-mini-environment
cd ~/opentofu-labs/lab-114-capstone-mini-environment
We will structure our project using modern enterprise separation of concerns:
lab-114-capstone-mini-environment/
βββ providers.tf # OpenTofu & AWS provider declarations with default tags
βββ variables.tf # Input variable definitions with validation rules
βββ terraform.tfvars # Environment-specific configuration values
βββ locals.tf # Computed values, naming conventions, and common metadata
βββ vpc.tf # Multi-AZ VPC, subnets, IGW, and route tables
βββ security.tf # Security groups and ingress/egress rules
βββ iam.tf # IAM roles, instance profiles, and least-privilege policy documents
βββ storage.tf # Hardened S3 bucket, versioning, encryption, and bucket policy
βββ compute.tf # Multi-AZ EC2 instances, EBS hardening, and cloud-init bootstrap
βββ outputs.tf # Operational endpoints, connection strings, and verification commands
π Step 2: Providers Configuration
Create providers.tf:
# providers.tf
terraform {
required_version = ">= 1.8.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}
βοΈ Step 3: Variables & Custom Validation
Create variables.tf. Notice our use of typed variables and custom validation rules to catch errors before tofu apply runs:
# variables.tf
variable "aws_region" {
description = "Target AWS region for infrastructure deployment"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Deployment environment name"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "project_name" {
description = "Project name prefix used for resource naming"
type = string
default = "mini-cloud"
}
variable "vpc_cidr" {
description = "Base CIDR block for the custom VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrnetmask(var.vpc_cidr))
error_message = "The vpc_cidr value must be a valid IPv4 CIDR block."
}
}
variable "instance_type" {
description = "EC2 compute instance type"
type = string
default = "t3.micro"
}
Create terraform.tfvars:
# terraform.tfvars
aws_region = "us-east-1"
environment = "dev"
project_name = "mini-cloud"
vpc_cidr = "10.0.0.0/16"
instance_type = "t3.micro"
π·οΈ Step 4: Locals & Dynamic Naming
Create locals.tf:
# locals.tf
locals {
name_prefix = "${var.project_name}-${var.environment}"
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "OpenTofu"
Lab = "114-Capstone-Mini-Environment"
}
}
π Step 5: Networking Topology (VPC, Subnets, Routing)
Create vpc.tf. We partition the VPC into 2 Public Subnets and 2 Private Subnets across distinct Availability Zones:
# vpc.tf
# 1. Discover available Availability Zones in the current region
data "aws_availability_zones" "available" {
state = "available"
}
# 2. Custom VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${local.name_prefix}-vpc"
}
}
# 3. Internet Gateway
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-igw"
}
}
# 4. Public Subnet 1 (AZ-A)
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${local.name_prefix}-public-subnet-1"
Tier = "Public"
}
}
# 5. Public Subnet 2 (AZ-B)
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = true
tags = {
Name = "${local.name_prefix}-public-subnet-2"
Tier = "Public"
}
}
# 6. Private Subnet 1 (AZ-A)
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.10.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "${local.name_prefix}-private-subnet-1"
Tier = "Private"
}
}
# 7. Private Subnet 2 (AZ-B)
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.20.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
tags = {
Name = "${local.name_prefix}-private-subnet-2"
Tier = "Private"
}
}
# 8. Public Route Table
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 = "${local.name_prefix}-public-rt"
}
}
# 9. Private Route Table (Local VPC traffic only)
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-private-rt"
}
}
# 10. Route Table Associations
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private_1" {
subnet_id = aws_subnet.private_1.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_2" {
subnet_id = aws_subnet.private_2.id
route_table_id = aws_route_table.private.id
}
π Step 6: Security Groups & Ingress Control
Create security.tf. We apply the Zero Inbound Port 22 SSH rule and set up Security Group referencing:
# security.tf
# 1. Web Tier Security Group (Attached to Public EC2 Instances)
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg"
description = "Allow inbound HTTP from internet; zero SSH ports"
vpc_id = aws_vpc.main.id
ingress {
description = "Allow HTTP from world"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# Zero Inbound Port 22! (SSM handles access out-of-band)
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.name_prefix}-web-sg"
}
}
# 2. Backend / Database Security Group (Chained to Web SG)
resource "aws_security_group" "backend" {
name = "${local.name_prefix}-backend-sg"
description = "Allow traffic strictly from Web Security Group"
vpc_id = aws_vpc.main.id
ingress {
description = "Allow communication strictly from Web Tier"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id] # << SG REFERENCING!
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${local.name_prefix}-backend-sg"
}
}
π¦ Step 7: Hardened S3 Application Storage
Create storage.tf. We deploy an S3 bucket with complete production security controls:
# storage.tf
# 1. Random Suffix for Global Bucket Uniqueness
resource "random_id" "bucket_suffix" {
byte_length = 4
}
# 2. Main Application Bucket
resource "aws_s3_bucket" "app_data" {
bucket = "${local.name_prefix}-data-${random_id.bucket_suffix.hex}"
force_destroy = true
tags = {
Name = "${local.name_prefix}-app-data"
}
}
# 3. Bucket Ownership Controls
resource "aws_s3_bucket_ownership_controls" "app_data" {
bucket = aws_s3_bucket.app_data.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
# 4. Public Access Block (100% Private)
resource "aws_s3_bucket_public_access_block" "app_data" {
bucket = aws_s3_bucket.app_data.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# 5. Object Versioning
resource "aws_s3_bucket_versioning" "app_data" {
bucket = aws_s3_bucket.app_data.id
versioning_configuration {
status = "Enabled"
}
}
# 6. Default Server-Side Encryption (SSE-S3)
resource "aws_s3_bucket_server_side_encryption_configuration" "app_data" {
bucket = aws_s3_bucket.app_data.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# 7. Bucket Policy Enforcing TLS (In-Transit Encryption)
data "aws_iam_policy_document" "s3_tls_only" {
statement {
sid = "EnforceTLSRequestsOnly"
effect = "Deny"
actions = ["s3:*"]
resources = [
aws_s3_bucket.app_data.arn,
"${aws_s3_bucket.app_data.arn}/*"
]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "tls_only" {
bucket = aws_s3_bucket.app_data.id
policy = data.aws_iam_policy_document.s3_tls_only.json
}
π Step 8: IAM Roles & Scoped Least-Privilege
Create iam.tf. We craft an IAM role allowing EC2 instances to communicate with SSM and access only our application S3 bucket:
# iam.tf
# 1. EC2 Assume Role Trust Policy
data "aws_iam_policy_document" "ec2_trust" {
statement {
actions = ["sts:AssumeRole"]
effect = "Allow"
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "app_ec2" {
name = "${local.name_prefix}-ec2-role"
assume_role_policy = data.aws_iam_policy_document.ec2_trust.json
tags = {
Name = "${local.name_prefix}-ec2-role"
}
}
# 2. Attach AWS Managed SSM Policy for Zero-Trust Session Manager
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.app_ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
# 3. Custom Scoped S3 Policy Document (Least Privilege)
data "aws_iam_policy_document" "s3_access" {
statement {
sid = "AllowAppBucketList"
effect = "Allow"
actions = [
"s3:ListBucket",
"s3:GetBucketLocation"
]
resources = [aws_s3_bucket.app_data.arn]
}
statement {
sid = "AllowAppBucketReadWrite"
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject"
]
resources = ["${aws_s3_bucket.app_data.arn}/*"]
}
}
resource "aws_iam_policy" "s3_access" {
name = "${local.name_prefix}-s3-access-policy"
description = "Scoped read/write access to application bucket"
policy = data.aws_iam_policy_document.s3_access.json
}
resource "aws_iam_role_policy_attachment" "s3_access" {
role = aws_iam_role.app_ec2.name
policy_arn = aws_iam_policy.s3_access.arn
}
# 4. Instance Profile for EC2 Attachment
resource "aws_iam_instance_profile" "app_ec2" {
name = "${local.name_prefix}-instance-profile"
role = aws_iam_role.app_ec2.name
}
π» Step 9: Multi-AZ Compute Tier
Create compute.tf. We discover the latest Amazon Linux 2023 AMI dynamically, and deploy two redundant instances across AZ-A and AZ-B:
# compute.tf
# 1. Dynamically discover the latest Amazon Linux 2023 AMI
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-x86_64"]
}
filter {
name = "state"
values = ["available"]
}
}
# 2. Cloud-Init User Data script template
locals {
user_data_script = <<-EOF
#!/bin/bash
dnf update -y
dnf install -y nginx awscli
systemctl enable nginx
systemctl start nginx
# Fetch instance metadata via IMDSv2
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
INSTANCE_ID=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/instance-id)
AVAIL_ZONE=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/placement/availability-zone)
# Test S3 Access via IAM Role
echo "EC2 Bootstrapping Test - $(date)" > /tmp/test-boot.txt
aws s3 cp /tmp/test-boot.txt s3://${aws_s3_bucket.app_data.id}/bootstrap-$INSTANCE_ID.txt
cat <<HTML > /usr/share/nginx/html/index.html
<!DOCTYPE html>
<html>
<head>
<title>OpenTofu Capstone - Mini Cloud</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #f8fafc; padding: 40px; }
.card { background: #1e293b; border-radius: 12px; padding: 24px; max-width: 600px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); }
h1 { color: #38bdf8; margin-top: 0; }
.meta { color: #94a3b8; font-size: 14px; margin-bottom: 8px; }
.badge { display: inline-block; background: #0284c7; color: white; padding: 4px 10px; border-radius: 6px; font-weight: bold; }
.success { color: #4ade80; font-weight: bold; }
</style>
</head>
<body>
<div class="card">
<h1>Mini AWS Cloud: Online</h1>
<p class="meta">Environment: <span class="badge">${var.environment}</span></p>
<p><strong>Instance ID:</strong> $INSTANCE_ID</p>
<p><strong>Availability Zone:</strong> $AVAIL_ZONE</p>
<p class="success">✔ IAM Role S3 Write Test Passed (Uploaded bootstrap-$INSTANCE_ID.txt)</p>
</div>
</body>
</html>
HTML
EOF
}
# 3. Instance A (Public Subnet 1 - AZ-A)
resource "aws_instance" "web_a" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = aws_subnet.public_1.id
iam_instance_profile = aws_iam_instance_profile.app_ec2.name
vpc_security_group_ids = [aws_security_group.web.id]
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
user_data = local.user_data_script
tags = {
Name = "${local.name_prefix}-web-server-a"
Zone = "AZ-A"
}
}
# 4. Instance B (Public Subnet 2 - AZ-B)
resource "aws_instance" "web_b" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = aws_subnet.public_2.id
iam_instance_profile = aws_iam_instance_profile.app_ec2.name
vpc_security_group_ids = [aws_security_group.web.id]
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
user_data = local.user_data_script
tags = {
Name = "${local.name_prefix}-web-server-b"
Zone = "AZ-B"
}
}
π€ Step 10: Operational Outputs
Create outputs.tf:
# outputs.tf
output "vpc_id" {
description = "Custom VPC ID"
value = aws_vpc.main.id
}
output "s3_bucket_name" {
description = "Provisioned application S3 bucket name"
value = aws_s3_bucket.app_data.id
}
output "web_server_a_public_ip" {
description = "Public IP of Web Server A (AZ-A)"
value = aws_instance.web_a.public_ip
}
output "web_server_b_public_ip" {
description = "Public IP of Web Server B (AZ-B)"
value = aws_instance.web_b.public_ip
}
output "test_web_a" {
description = "Curl test command for Web Server A"
value = "curl -s http://${aws_instance.web_a.public_ip} | grep 'Mini AWS Cloud'"
}
output "test_web_b" {
description = "Curl test command for Web Server B"
value = "curl -s http://${aws_instance.web_b.public_ip} | grep 'Mini AWS Cloud'"
}
output "ssm_connect_server_a" {
description = "SSM Session Manager command for Web Server A"
value = "aws ssm start-session --target ${aws_instance.web_a.id}"
}
output "ssm_connect_server_b" {
description = "SSM Session Manager command for Web Server B"
value = "aws ssm start-session --target ${aws_instance.web_b.id}"
}
π Step 11: Deployment & Automated Validation
We now run our complete deployment lifecycle:
1. Initialize and Validate Syntax
tofu init
tofu fmt -check
tofu validate
Expected Output:
Success! The configuration is valid.
2. Plan and Review the Dependency Graph
tofu plan -out=tfplan
OpenTofu will calculate the complete DAG across 24 resources. Review the plan summary:
Plan: 24 to add, 0 to change, 0 to destroy.
3. Apply the Plan
tofu apply tfplan
Wait roughly 90 seconds for OpenTofu to construct the network, configure IAM policies, create the S3 bucket, and launch the two compute instances.
π§ͺ Step 12: Comprehensive Operational Verification
Once the apply completes, run the following verification battery:
1. Test Web Server Ingress in Both Availability Zones
Run the curl commands from your outputs:
curl -s http://$(tofu output -raw web_server_a_public_ip) | grep "Availability Zone"
curl -s http://$(tofu output -raw web_server_b_public_ip) | grep "Availability Zone"
Expected Output:
<p><strong>Availability Zone:</strong> us-east-1a</p>
<p><strong>Availability Zone:</strong> us-east-1b</p>
Both instances are serving live HTTP traffic from independent physical Availability Zones!
2. Verify Zero-Trust SSM Out-of-Band Management
Confirm that you can open an encrypted shell session to Server A without any open SSH ports:
aws ssm start-session --target $(tofu state show aws_instance.web_a | grep '^ id ' | awk '{print $3}' | tr -d '"')
You are now inside the Linux terminal via SSM Session Manager:
Starting session with SessionId: bot-0123456789abcdef0
sh-5.2$
3. Verify Least-Privilege S3 Access Inside EC2
Inside the SSM session, verify that the instance can write to our designated application bucket:
BUCKET_NAME=$(curl -s http://169.254.169.254/latest/meta-data/instance-id) # Just an ID
# List the objects in our application bucket:
aws s3 ls s3://$(cat /usr/share/nginx/html/index.html | grep bootstrap | awk -F'bootstrap-' '{print $2}' | awk -F'.' '{print $1}') # Or test with direct bucket name
Exit the SSM session:
exit
From your local machine, inspect the S3 bucket contents using the AWS CLI:
aws s3 ls s3://$(tofu output -raw s3_bucket_name)
Expected Output:
2026-09-16 18:50:00 35 bootstrap-i-01a2b3c4d5e6f7.txt
2026-09-16 18:50:02 35 bootstrap-i-09f8e7d6c5b4a3.txt
Both instances successfully executed aws s3 cp during boot using their attached IAM instance profile, with zero hardcoded credentials!
π§Ή Step 13: Clean Teardown
Before destroying the infrastructure, remember that our S3 bucket contains the bootstrap files uploaded by the instances. Because we set force_destroy = true on aws_s3_bucket.app_data, OpenTofu will automatically empty and delete the bucket during teardown!
Run:
tofu destroy -auto-approve
Output:
aws_instance.web_a: Destroying...
aws_instance.web_b: Destroying...
...
aws_s3_bucket.app_data: Destroying...
aws_vpc.main: Destroying...
Destroy complete! Resources: 24 destroyed.
Verify that zero resources remain in AWS:
aws ec2 describe-instances --filters "Name=tag:Project,Values=mini-cloud" "Name=instance-state-name,Values=running" --query "Reservations[].Instances[].InstanceId" --output text
(Returns empty. Everything destroyed cleanly).
ποΈ Hands-On Architectural Challenge
Before moving to the final challenge lab:
- Add a Second Storage Bucket:
Add an
aws_s3_bucket.audit_archiveresource with 90-day expiration lifecycle rules. - Update the IAM Least-Privilege Policy:
Update
data.aws_iam_policy_document.s3_accessiniam.tfso that EC2 instances have Read-Only access to the new audit archive bucket, but maintain Read/Write access to the main application bucket. - Run
tofu planto observe the surgical policy change without recreating the EC2 instances!
π Summary & Key Takeaways
In this capstone lab, you synthesized the core competencies of cloud infrastructure engineering:
- Complete Modular Architecture: You built a cohesive, multi-file infrastructure stack using
providers.tf,variables.tf,locals.tf,vpc.tf,security.tf,storage.tf,iam.tf,compute.tf, andoutputs.tf. - Resilience by Design: Multi-AZ public and private subnets with redundant compute workloads across two distinct Availability Zones.
- Hardened Security Posture:
- Zero open inbound management ports (SSH port 22 closed).
- Systems Manager Session Manager for out-of-band access.
- IMDSv2 token enforcement on compute instances.
- Encrypted gp3 root block devices.
- S3 public access blocks and mandatory TLS transport enforcement.
- Least-Privilege Authorization: Fine-grained IAM policy documents granting compute instances access strictly to designated cloud storage.
- Operational Predictability: Dynamic AMI discovery, typed input validation, clean output reporting, and safe teardown mechanics.
π Whatβs Next? (Lab 115)
You have completed the first capstone! You have demonstrated that you can build enterprise-grade infrastructure by following architectural blueprints.
Now comes the ultimate test: [AWS OpenTofu #115: Final Level 100 Challenge].
In Lab 115:
- There are no step-by-step code blocks.
- You are handed a real-world Client Requirements Document (RFP).
- You must design the architecture, write the OpenTofu code, resolve an intentional drift crisis, and audit your infrastructure independently.
- This is the final graduation exam for Level 100!