Cloud 55 min read

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.

Mohammad Rizky Prawira portrait
Mohammad Rizky Prawira
terminal Series Guide β€’ Part 14 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.

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

ResourceConfigurationFree Tier CostStandard Account (Past Free Tier)
Amazon EC2 Virtual Machines2x t3.micro On-Demand (~1 hour)$0.00~$0.024 / hour
Amazon EBS Storage2x 20 GB gp3 Root Volumes (~1 hour)$0.00~$0.004 / hour
Public IPv4 Addresses2x Auto-assigned IPv4 (~1 hour)$0.00$0.010 / hour ($0.005/ea)
Custom VPC & Subnets1 VPC, 4 Subnets, 2 Route Tables, 1 IGW$0.00$0.00 (Always 100% Free)
Amazon S3 Storage1 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 CostComplete 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.micro instances 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:

  1. Multi-AZ Availability: Workloads must survive an individual AWS data center failure. We partition our network across two dynamic Availability Zones (us-east-1a and us-east-1b) and deploy redundant EC2 web instances.
  2. 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.
  3. 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.
  4. 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.
  5. 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">&#10004; 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:

  1. Add a Second Storage Bucket: Add an aws_s3_bucket.audit_archive resource with 90-day expiration lifecycle rules.
  2. Update the IAM Least-Privilege Policy: Update data.aws_iam_policy_document.s3_access in iam.tf so that EC2 instances have Read-Only access to the new audit archive bucket, but maintain Read/Write access to the main application bucket.
  3. Run tofu plan to 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:

  1. 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, and outputs.tf.
  2. Resilience by Design: Multi-AZ public and private subnets with redundant compute workloads across two distinct Availability Zones.
  3. 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.
  4. Least-Privilege Authorization: Fine-grained IAM policy documents granting compute instances access strictly to designated cloud storage.
  5. 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!

Related & Recommended Guides

Continue exploring related systems architectures and engineering field notes.