AWS OpenTofu #203: Compose Infrastructure Modules
Orchestrate multi-tiered architectures by composing focused child modules. Pass outputs as inputs across networking, security, and compute layers, and master dependency graphs.
AWS + OpenTofu Level 200: Build a Reusable AWS Application Platform
Part of the CLI-first comprehensive learning path from foundational setup to production cloud architectures.
In Lab 202, you built your first reusable child module: an encapsulated multi-AZ VPC package.
Now, a critical architectural question arises: How do multiple independent modules work together to create a cohesive cloud platform?
A complete application environment requires networking, security firewalls, compute workloads, databases, and identity. If you cram all of those into one giant module, you recreate the dreaded βGod Moduleβ. But if you keep them separate, how do you wire them together cleanly?
How does an EC2 instance know which subnet to launch into? How does a Security Group bind itself to a VPC created by another module? And how does OpenTofu determine the exact order in which to create, update, or destroy these cross-module resources?
The answer is Module Composition.
In this lab, you will master the art of infrastructure composition. You will build three focused, single-responsibility modules (vpc, security-groups, ec2), compose them in your root orchestration layer, pipe outputs into inputs, master implicit dependency graphs, and learn why overusing depends_on is one of the most destructive habits in Infrastructure as Code.
π― Learning Objectives
| Modular Composition | Dependency Graphs & DAG | Operational Rigor |
|---|---|---|
| β’ The Root Module as the orchestration layer | β’ Implicit dependencies via attribute referencing | β’ Multi-module state tracking (module.<A>.<B>) |
| β’ Constructing single-responsibility child modules | β’ Explicit dependencies (depends_on) and anti-patterns | β’ Verifying SSM console access across composed tiers |
| β’ Piping module outputs directly into module inputs | β’ Concurrency & parallel provisioning mechanics | β’ Safe teardown of interdependent module graphs |
π° Estimated Lab Cost
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| Amazon EC2 Virtual Machine | 1x 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, 1 IGW | $0.00 | $0.00 (Always 100% Free) |
| Security Groups & IAM | Scoped Web SG & SSM Instance Profile | $0.00 | $0.00 (Always 100% Free) |
| AWS Systems Manager (SSM) | Zero-trust interactive console 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 configuration (VPC, Subnets, Route Tables, Internet Gateway) and SSM management are 100% free forever. Your only expense is running one
t3.microinstance for testing (~2 cents if your account has passed Free Tier). We will destroy all resources at the end of the lab.
ποΈ Architecture: The 3-Tier Module Composition
Rather than building a monolithic configuration, we divide our system into three specialized modules orchestrated by a root composition layer:
ROOT MODULE (Orchestration Layer)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β 1. module "network" (modules/vpc) β
β β’ Creates VPC (10.10.0.0/16), Subnets, IGW, Route Tables β
β β’ Exports: vpc_id, public_subnet_ids β
β β β
β βΌ passes: vpc_id β
β 2. module "security" (modules/security-groups) β
β β’ Creates Web SG (Port 80 Ingress, Zero SSH Port 22) β
β β’ Exports: web_sg_id β
β β β
β βΌ passes: public_subnet_ids[0], web_sg_id β
β 3. module "compute" (modules/ec2) β
β β’ Creates EC2 Instance (t3.micro), IMDSv2, gp3 EBS, SSM IAM Profile β
β β’ Exports: instance_id, public_ip β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The Data Flow Contract
Look at how data flows naturally between the modules:
module.networkruns first because nothing depends on it. It outputsvpc_idandpublic_subnet_ids.module.securityreceivesvpc_id = module.network.vpc_id. OpenTofu instantly knows thatsecuritydepends onnetwork.module.computereceivessubnet_id = module.network.public_subnet_ids[0]andsecurity_group_id = module.security.web_sg_id. OpenTofu knows thatcomputemust wait for bothnetworkandsecurityto finish.
π§ Deep Dive: Implicit vs. Explicit Dependencies
Understanding how OpenTofu builds its dependency graph is the difference between junior copy-pasters and senior platform engineers.
1. Implicit Dependencies (The Standard Way)
Whenever you write:
module "security" {
source = "./modules/security-groups"
vpc_id = module.network.vpc_id # << IMPLICIT DEPENDENCY!
}
OpenTofu analyzes the expression module.network.vpc_id. It infers an implicit dependency:
module.networkmust be evaluated and created beforemodule.security.- During destruction (
tofu destroy), OpenTofu automatically reverses the order:module.securityis destroyed beforemodule.network.
You do not need to write depends_on. The attribute reference communicates the relationship with 100% mathematical precision.
2. Explicit Dependencies (depends_on) and Its Traps
The depends_on meta-argument allows you to manually force an order when an attribute reference does not exist:
# USE SPARINGLY: Explicit Dependency
resource "aws_s3_bucket_policy" "example" {
bucket = aws_s3_bucket.example.id
policy = data.aws_iam_policy_document.example.json
depends_on = [aws_s3_bucket_public_access_block.example]
}
Here, depends_on is valid because S3 bucket policies will fail if the public access block is not yet active, but the policy resource does not take an attribute from the access block.
The Disaster of depends_on on Entire Modules:
Beginners often slap depends_on = [module.network] onto their module blocks:
# β ANTI-PATTERN: DO NOT DO THIS!
module "compute" {
source = "./modules/ec2"
subnet_id = module.network.public_subnet_ids[0]
depends_on = [module.network] # β REDUNDANT & HARMFUL!
}
Why this is harmful:
- Destroys Concurrency: When you put
depends_onon a module, OpenTofu treats the entire module as a single opaque block. OpenTofu cannot calculate plan diffs for the child module until the upstream module is 100% applied. - False Replacements: If anything in
module.networkis updated in-place (e.g., adding a tag), OpenTofu may assume the downstream module is compromised and trigger unnecessary resource replacements! - Redundant Noise: If you already pass
module.network.public_subnet_ids[0], OpenTofu already guarantees the order. Addingdepends_onadds zero value and massive overhead.
[!IMPORTANT] Golden Rule: Rely on implicit dependencies via output-to-input attribute referencing. Only use
depends_onwhen a dependency exists in the physical AWS cloud that OpenTofu cannot see in HCL code (such as IAM role propagation delays).
ποΈ Step 1: Workspace Directory Structure
Letβs set up our project workspace with three dedicated child modules under modules/:
mkdir -p ~/opentofu-labs/lab-203-module-composition/modules/vpc
mkdir -p ~/opentofu-labs/lab-203-module-composition/modules/security-groups
mkdir -p ~/opentofu-labs/lab-203-module-composition/modules/ec2
cd ~/opentofu-labs/lab-203-module-composition
Our complete architecture will be structured as follows:
lab-203-module-composition/
βββ modules/
β βββ vpc/ # Network Layer (VPC, Subnets, IGW, Route Tables)
β β βββ main.tf
β β βββ variables.tf
β β βββ outputs.tf
β βββ security-groups/ # Firewall Layer (Stateful Security Groups)
β β βββ main.tf
β β βββ variables.tf
β β βββ outputs.tf
β βββ ec2/ # Compute Layer (EC2 Instance, SSM IAM, User Data)
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
βββ versions.tf # Engine & provider version constraints
βββ providers.tf # Provider setup & default tags
βββ variables.tf # Root inputs
βββ locals.tf # Root naming conventions & common tags
βββ main.tf # ROOT COMPOSITION LAYER (Chaining the modules)
βββ outputs.tf # Public endpoints and operational verification
π Step 2: Module 1 β Networking (modules/vpc)
Letβs create the networking child module.
modules/vpc/variables.tf
# modules/vpc/variables.tf
variable "name_prefix" {
description = "Prefix prepended to resource names"
type = string
}
variable "vpc_cidr" {
description = "Base CIDR block for the VPC"
type = string
default = "10.10.0.0/16"
}
variable "availability_zones" {
description = "List of two Availability Zones"
type = list(string)
}
modules/vpc/main.tf
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.name_prefix}-vpc"
}
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.name_prefix}-igw"
}
}
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 1) # e.g. 10.10.1.0/24
availability_zone = var.availability_zones[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.name_prefix}-public-1"
Tier = "Public"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 2) # e.g. 10.10.2.0/24
availability_zone = var.availability_zones[1]
map_public_ip_on_launch = true
tags = {
Name = "${var.name_prefix}-public-2"
Tier = "Public"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = {
Name = "${var.name_prefix}-public-rt"
}
}
resource "aws_route_table_association" "pub_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "pub_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
modules/vpc/outputs.tf
# modules/vpc/outputs.tf
output "vpc_id" {
description = "The ID of the provisioned custom VPC"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = [aws_subnet.public_1.id, aws_subnet.public_2.id]
}
π‘οΈ Step 3: Module 2 β Security Firewalls (modules/security-groups)
Now create the security groups module. This module takes vpc_id as an input and creates an ingress-restricted security group:
modules/security-groups/variables.tf
# modules/security-groups/variables.tf
variable "name_prefix" {
description = "Prefix prepended to resource names"
type = string
}
variable "vpc_id" {
description = "The VPC ID where the security groups will be created"
type = string
}
modules/security-groups/main.tf
# modules/security-groups/main.tf
resource "aws_security_group" "web" {
name = "${var.name_prefix}-web-sg"
description = "Allow inbound HTTP; zero inbound SSH"
vpc_id = var.vpc_id
# Inbound HTTP from world
ingress {
description = "Allow HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# STRICT ZERO-TRUST: Port 22 SSH closed!
# Outbound all
egress {
description = "Allow all outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.name_prefix}-web-sg"
}
}
modules/security-groups/outputs.tf
# modules/security-groups/outputs.tf
output "web_security_group_id" {
description = "ID of the web tier security group"
value = aws_security_group.web.id
}
π» Step 4: Module 3 β Compute Workloads (modules/ec2)
Now create the compute child module. It requires a subnet_id (from the VPC module) and a security_group_id (from the security module). It also packages an IAM role for out-of-band AWS Systems Manager Session Manager access:
modules/ec2/variables.tf
# modules/ec2/variables.tf
variable "name_prefix" {
description = "Prefix prepended to resource names"
type = string
}
variable "subnet_id" {
description = "The subnet ID where the instance will be placed"
type = string
}
variable "security_group_id" {
description = "The security group ID to attach to the instance"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
modules/ec2/main.tf
# modules/ec2/main.tf
# 1. Discover latest official 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. IAM Role for Zero-Trust SSM Session Manager
resource "aws_iam_role" "this" {
name = "${var.name_prefix}-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.this.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "this" {
name = "${var.name_prefix}-instance-profile"
role = aws_iam_role.this.name
}
# 3. EC2 Instance
resource "aws_instance" "this" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = var.subnet_id
iam_instance_profile = aws_iam_instance_profile.this.name
vpc_security_group_ids = [var.security_group_id]
# Root block device hardening
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}
# Hardened IMDSv2
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
http_put_response_hop_limit = 1
}
user_data = <<-EOF
#!/bin/bash
dnf update -y
dnf install -y nginx
systemctl enable nginx
systemctl start nginx
echo "<h1>Module Composition Active</h1><p>VPC + Security Group + EC2 Modules Chained Successfully</p>" > /usr/share/nginx/html/index.html
EOF
tags = {
Name = "${var.name_prefix}-web-server"
}
}
modules/ec2/outputs.tf
# modules/ec2/outputs.tf
output "instance_id" {
description = "ID of the provisioned EC2 instance"
value = aws_instance.this.id
}
output "public_ip" {
description = "Public IPv4 address of the EC2 instance"
value = aws_instance.this.public_ip
}
πΌ Step 5: The Root Composition Layer (main.tf)
Now we return to the root folder and write our orchestration files.
1. Root versions.tf & providers.tf
Create versions.tf:
# versions.tf
terraform {
required_version = ">= 1.8.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
}
Create providers.tf:
# providers.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}
2. Root variables.tf & locals.tf
Create variables.tf:
# variables.tf
variable "aws_region" {
type = string
default = "us-east-1"
}
variable "environment" {
type = string
default = "dev"
}
variable "project_name" {
type = string
default = "composite-stack"
}
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 = "203-Compose-Modules"
}
}
3. Root main.tf: Chaining the Modules Together!
This is where the magic happens. Look at how readable, declarative, and expressive the root main.tf is:
# main.tf (THE ROOT COMPOSITION LAYER)
# 1. Discover Availability Zones
data "aws_availability_zones" "available" {
state = "available"
}
# 2. Tier 1: Networking Module
module "network" {
source = "./modules/vpc"
name_prefix = local.name_prefix
vpc_cidr = "10.10.0.0/16"
availability_zones = slice(data.aws_availability_zones.available.names, 0, 2)
}
# 3. Tier 2: Security Groups Module (Consumes VPC Output)
module "security" {
source = "./modules/security-groups"
name_prefix = local.name_prefix
vpc_id = module.network.vpc_id # << PIPED FROM NETWORK MODULE!
}
# 4. Tier 3: Compute Module (Consumes Network & Security Outputs)
module "compute" {
source = "./modules/ec2"
name_prefix = local.name_prefix
subnet_id = module.network.public_subnet_ids[0] # << PIPED FROM NETWORK!
security_group_id = module.security.web_security_group_id # << PIPED FROM SECURITY!
}
Look at the elegance of this structure:
main.tfis only 30 lines long, yet it orchestrates a complete 3-tier cloud architecture.- We have zero hardcoded IDs.
- OpenTofu automatically infers the Directed Acyclic Graph (DAG) without a single line of
depends_on.
4. Root outputs.tf
Create outputs.tf to expose operational commands:
# outputs.tf
output "web_url" {
description = "HTTP URL of the web server"
value = "http://${module.compute.public_ip}"
}
output "ssm_connect" {
description = "AWS CLI command to connect via SSM Session Manager"
value = "aws ssm start-session --target ${module.compute.instance_id}"
}
output "vpc_id" {
description = "Underlying VPC ID"
value = module.network.vpc_id
}
π Step 6: Initialization and Deployment
Run tofu init to register all three child modules:
tofu init
Output:
Initializing modules...
- compute in modules/ec2
- network in modules/vpc
- security in modules/security-groups
...
OpenTofu has been successfully initialized!
Verify formatting and syntax:
tofu fmt -recursive
tofu validate
Now run tofu plan:
tofu plan
Notice the plan summary:
Plan: 12 to add, 0 to change, 0 to destroy.
OpenTofu recognized all resources across the three modules:
module.network.*(VPC, Subnets, IGW, Routes)module.security.aws_security_group.webmodule.compute.aws_instance.this& IAM roles
Apply the deployment:
tofu apply -auto-approve
Wait roughly 60 seconds for the EC2 instance to launch and bootstrap Nginx.
π§ͺ Step 7: Verification Across Composed Tiers
Letβs test our live infrastructure:
1. Test Web Server Ingress
Query the web server endpoint from the output:
curl -Iv --connect-timeout 5 $(tofu output -raw web_url)
Expected Response:
* Connected to 54.x.x.x port 80
< HTTP/1.1 200 OK
< Server: nginx/1.24.0
<
<h1>Module Composition Active</h1><p>VPC + Security Group + EC2 Modules Chained Successfully</p>
2. Verify Zero-Trust SSM Console Access
Verify that we can establish an encrypted out-of-band terminal session without open SSH ports:
$(tofu output -raw ssm_connect)
Inside the session:
sh-5.2$ whoami
ssm-user
sh-5.2$ exit
3. Inspect Composed State
List the state resources to see the multi-tier hierarchy:
tofu state list
Output:
module.compute.aws_iam_instance_profile.this
module.compute.aws_iam_role.this
module.compute.aws_iam_role_policy_attachment.ssm
module.compute.aws_instance.this
module.network.aws_internet_gateway.this
module.network.aws_route_table.public
module.network.aws_route_table_association.pub_1
module.network.aws_route_table_association.pub_2
module.network.aws_subnet.public_1
module.network.aws_subnet.public_2
module.network.aws_vpc.this
module.security.aws_security_group.web
π οΈ Common Troubleshooting & Gotchas
1. Error: Cycle: module.compute -> module.security -> module.compute
- Cause: Creating circular output-to-input loops between modules (e.g., passing an output from Module A into Module B, while simultaneously trying to pass an output from Module B into Module A).
- Rule: Infrastructure dependencies must strictly form a Directed Acyclic Graph (DAG). Data can only flow downstream, never backwards.
2. Output Not Found: module.network has no output "public_subnets"
- Cause: Typo between what the child module exported in
modules/vpc/outputs.tf(public_subnet_ids) and what the root module referenced (public_subnets). - Rule: Root module references must match the exact block label declared in the childβs
outputs.tf.
3. Concurrency Lockup Caused by Module-Level depends_on
- Symptom:
tofu plantakes minutes to evaluate in large codebases. - Cause: Putting
depends_on = [module.network]on downstream modules. - Fix: Delete the module-level
depends_on. Let attribute references handle dependency timing naturally.
π§Ή Step 8: Clean Teardown
Destroy all resources across the three composed tiers:
tofu destroy -auto-approve
Output:
module.compute.aws_instance.this: Destroying...
...
module.security.aws_security_group.web: Destroying...
...
module.network.aws_vpc.this: Destroying...
Destroy complete! Resources: 12 destroyed.
Notice the destruction order: OpenTofu terminated the EC2 instance first, deleted the security group second, and removed the VPC last. Reverse dependency resolution executed automatically!
ποΈ Hands-On Challenge
Before moving to Lab 204:
- Inside
modules/security-groups, create a second Security Group:aws_security_group.dbdesigned for an internal database (Port 5432). - Use Security Group Chaining: make its ingress rule allow traffic strictly from
aws_security_group.web.id(security_groups = [aws_security_group.web.id]). - Export
db_security_group_idfrommodules/security-groups/outputs.tf. - In your root
outputs.tf, exposeoutput "db_sg_id" { value = module.security.db_security_group_id }. - Run
tofu planto observe how OpenTofu expands the firewall tier without disturbing compute or networking!
π Summary & Key Takeaways
In this lab, you mastered the standard architecture of enterprise cloud engineering:
- The Composition Pattern: The root module acts as an orchestrator, declaring child modules and connecting them via input/output data pipelines.
- Single Responsibility: Decomposing infrastructure into focused modules (
vpc,security-groups,ec2) prevents blast-radius catastrophes and keeps code maintainable. - Implicit Dependencies: Referencing
module.network.vpc_idautomatically builds the correct Directed Acyclic Graph (DAG) for provisioning and reverse teardown. - Avoid Module
depends_on: Do not placedepends_onon entire module blocks; it serializes execution, eliminates concurrency, and causes false resource replacements. - Clean Interface Contracts: Child modules should remain strictly encapsulated, exposing only high-level identifiers through
outputs.tf.
π Whatβs Next? (Lab 204)
In our current VPC and EC2 modules, we manually created subnets and security rules one by one (public_1, public_2).
What happens when you need to provision 12 subnets across 3 regions, or generate 20 security group rules from a dynamic map of ports?
In [AWS OpenTofu #204: for_each, count, and Dynamic Infrastructure], we will:
- Compare
countvs.for_each. - Uncover the βList Index Shiftβ trap that silently destroys production resources when using
count. - Master stable map addressing:
aws_subnet.this["app-a"]vs.aws_subnet.this[0]. - Construct dynamic nested blocks cleanly in HCL.