AWS OpenTofu #202: Your First Reusable OpenTofu Module
Package infrastructure into modular, encapsulated components. Build a reusable multi-AZ VPC child module with clear input contracts, outputs, and local sources.
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 201, we transformed a messy single-file script into an organized, multi-file root project.
However, our networking and compute configurations were still tightly coupled to that specific directory. If another team in your organization needed an identical multi-AZ VPC for their billing microservice, their only option would be to copy and paste your code.
Copy-paste infrastructure is an enterprise disaster:
- Bug fixes and security patches applied to Team A’s VPC never reach Team B’s VPC.
- Subnet CIDR conventions and naming rules diverge across projects.
- Maintaining dozens of cloned repositories turns minor AWS updates into months of tedious manual work.
In software engineering, you solve code duplication by creating functions and packages. In Infrastructure as Code with OpenTofu, you solve it by creating Reusable Modules.
In this lab, you will build your first production-grade Child Module: an encapsulated, multi-AZ VPC networking package. You will define strict input contracts, expose clean output interfaces, consume it locally using source = "./modules/vpc", and learn why the “God Module” anti-pattern must be avoided at all costs.
🎯 Learning Objectives
| Modular Design Patterns | Module Mechanics & Lifecycle | Operational Rigor |
|---|---|---|
| • Root modules vs. Child modules | • The module compilation & symlink engine (.terraform/modules/) | • State address naming for modules (module.<name>.<resource>) |
| • Module encapsulation & interface design | • Declaring inputs in child variables.tf | • Verifying clean encapsulation boundaries |
• Exposing stable outputs in child outputs.tf | • Local module sourcing (source = "./modules/...") | • Safe teardown of modular stacks |
| • The “God Module” anti-pattern vs. focused design | • Why tofu init is required when adding or updating modules | • Zero-cost networking validation |
💰 Estimated Lab Cost
| Resource | Configuration | Free Tier Cost | Standard Account (Past Free Tier) |
|---|---|---|---|
| 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) |
| Route Table Associations | Subnet routing associations | $0.00 | $0.00 (Always 100% Free) |
| Compute & NAT Gateways | Zero EC2 instances or NAT Gateways | $0.00 | $0.00 |
| Total Estimated Cost | Complete Hands-On Session (~1 hour) | $0.00 | $0.00 |
[!NOTE] All resources provisioned in this lab (VPCs, Subnets, Route Tables, Internet Gateways) are 100% free forever on AWS. Whether your account is in its first month or years past Free Tier, the total cost for this lab is $0.00.
🧠 Core Architecture: Root Modules vs. Child Modules
In OpenTofu, every directory containing .tf files is a module. The distinction is how it is executed:
[ Terminal: tofu apply ]
│
▼
┌────────────────────────────────────────────────────────┐
│ ROOT MODULE (The Composition Layer) │
│ • Runs where you type "tofu apply" │
│ • Owns backend configuration and provider instances │
│ • Passes environment-specific values into child modules│
│ │
│ module "network" { │
│ source = "./modules/vpc" │
│ vpc_cidr = "10.0.0.0/16" │
│ availability_zones = ["us-east-1a", "us-east-1b"] │
│ } │
└───────────┬────────────────────────────────────────────┘
│ Inputs (Variables)
▼
┌────────────────────────────────────────────────────────┐
│ CHILD MODULE (The Encapsulated Package) │
│ • Located in a subfolder (e.g. ./modules/vpc) │
│ • Does NOT configure providers or backends │
│ • Has zero access to root variables unless passed in │
│ • Creates VPC, Subnets, IGW, Route Tables │
│ • Exposes outputs: vpc_id, public_subnets, etc. │
└───────────┬────────────────────────────────────────────┘
│ Outputs
▼
[ Root Module: output "vpc_id" { value = module.network.vpc_id } ]
1. The Root Module (The Caller)
The root module is the working directory where you execute tofu plan and tofu apply. It is responsible for:
- Configuring the backend (where state lives).
- Configuring providers (AWS regions, credentials,
default_tags). - Deciding which modules to call and feeding them arguments.
2. The Child Module (The Callee)
A child module is a self-contained, parameterized blueprint located in a subfolder (or remote Git repository). It is responsible for:
- Declaring what inputs it needs in its own
variables.tf. - Provisioning internal cloud resources in its own
main.tf. - Explicitly returning useful attributes in its own
outputs.tf.
3. The Golden Rule of Encapsulation
A child module is a black box.
- The root module cannot see inside the child module. If the child module creates an
aws_subnetnamedpublic_1, the root module cannot readaws_subnet.public_1.id. - The root module can only access values that the child module explicitly exports through an
outputblock:module.network.public_subnet_ids. - Similarly, the child module cannot read root variables unless they are passed as arguments in the
moduleblock.
🚫 The “God Module” Anti-Pattern
When engineers discover modules, they often over-engineer. They try to build a single module that provisions a VPC, Security Groups, EC2 instances, RDS databases, ALBs, S3 buckets, and CloudWatch alarms:
# THE ANTI-PATTERN: The "God Module"
module "everything" {
source = "./modules/aws-infrastructure"
create_vpc = true
create_database = false
enable_monitoring = true
database_type = "postgres"
instance_count = 3
# ... 87 other boolean flags and conditional arguments
}
Why God Modules Fail in Production:
- Brittle Complexity: Dozens of
count = var.create_x ? 1 : 0flags make the code nearly impossible to debug or test. - Blast Radius: Modifying a database argument forces OpenTofu to evaluate the entire network and compute graph, risking accidental downtime.
- Rigid Abstraction: Every team has slightly different requirements. A monolithic module quickly becomes an unmaintainable maze of edge-case exceptions.
The Rule of Thumb: Single Responsibility
Build small, composable modules that do one thing well:
modules/vpc— Provisions networking only.modules/security-groups— Provisions firewall rules only.modules/compute— Provisions virtual machines or containers only.
In this lab, we build a clean, single-responsibility VPC Module.
🗂️ Step 1: Directory Setup
Let’s create our project workspace. We will have a root module and a child module located under modules/vpc/:
mkdir -p ~/opentofu-labs/lab-202-first-module/modules/vpc
cd ~/opentofu-labs/lab-202-first-module
Our project structure will look like this:
lab-202-first-module/
├── modules/
│ └── vpc/
│ ├── main.tf # Child module resources (VPC, Subnets, IGW, Routes)
│ ├── variables.tf # Child module input parameters
│ └── outputs.tf # Child module exported attributes
├── versions.tf # Root engine & provider constraints
├── providers.tf # Root provider configuration & default tags
├── variables.tf # Root input variables
├── locals.tf # Root computed prefixes
├── main.tf # Root module invoking child module
└── outputs.tf # Root outputs exposing module data
📦 Step 2: Building the Child Module
Let’s navigate into modules/vpc/ and build the child module.
cd modules/vpc
1. Child Module Inputs (modules/vpc/variables.tf)
The child module’s variables.tf defines the public API contract that consumers must fulfill.
Create variables.tf:
# modules/vpc/variables.tf
variable "project_name" {
description = "Project code name used for resource naming"
type = string
}
variable "environment" {
description = "Deployment environment tier (e.g. dev, staging, prod)"
type = string
}
variable "vpc_cidr" {
description = "IPv4 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 must be a valid IPv4 CIDR block (e.g., 10.0.0.0/16)."
}
}
variable "availability_zones" {
description = "List of exactly two Availability Zones for multi-AZ redundancy"
type = list(string)
validation {
condition = length(var.availability_zones) == 2
error_message = "You must provide exactly two Availability Zones."
}
}
Notice that we do not hardcode anything. The caller decides the project name, the CIDR block, and which two AZs to use.
2. Child Module Resources (modules/vpc/main.tf)
Now create main.tf. The child module uses standard OpenTofu functions like cidrsubnet() to dynamically carve out subnets from whatever var.vpc_cidr the caller provides:
# modules/vpc/main.tf
# 1. Custom VPC
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
}
}
# 2. Internet Gateway
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
}
}
# 3. Public Subnet 1 (AZ-A)
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 1) # e.g. 10.0.1.0/24
availability_zone = var.availability_zones[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public-1"
Tier = "Public"
}
}
# 4. Public Subnet 2 (AZ-B)
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 2) # e.g. 10.0.2.0/24
availability_zone = var.availability_zones[1]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public-2"
Tier = "Public"
}
}
# 5. Private Subnet 1 (AZ-A)
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 10) # e.g. 10.0.10.0/24
availability_zone = var.availability_zones[0]
tags = {
Name = "${var.project_name}-${var.environment}-private-1"
Tier = "Private"
}
}
# 6. Private Subnet 2 (AZ-B)
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, 20) # e.g. 10.0.20.0/24
availability_zone = var.availability_zones[1]
tags = {
Name = "${var.project_name}-${var.environment}-private-2"
Tier = "Private"
}
}
# 7. Public Route Table
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.project_name}-${var.environment}-public-rt"
}
}
# 8. Private Route Table (Local VPC routing only)
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
tags = {
Name = "${var.project_name}-${var.environment}-private-rt"
}
}
# 9. 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
}
[!TIP] Notice our naming convention inside the module: we name the primary VPC and Internet Gateway
aws_vpc.thisandaws_internet_gateway.this. Inside an encapsulated module, naming the central resourcethisis standard industry practice. When consumed by the caller, it will be addressed asmodule.network.aws_vpc.this.
3. Child Module Outputs (modules/vpc/outputs.tf)
The child module’s outputs.tf defines what data it sends back to the caller.
Create outputs.tf:
# modules/vpc/outputs.tf
output "vpc_id" {
description = "The ID of the custom VPC"
value = aws_vpc.this.id
}
output "vpc_cidr_block" {
description = "The CIDR block allocated to the VPC"
value = aws_vpc.this.cidr_block
}
output "public_subnet_ids" {
description = "List of public subnet IDs for web ingress"
value = [aws_subnet.public_1.id, aws_subnet.public_2.id]
}
output "private_subnet_ids" {
description = "List of private subnet IDs for isolated workloads"
value = [aws_subnet.private_1.id, aws_subnet.private_2.id]
}
output "public_route_table_id" {
description = "ID of the route table routing to the Internet Gateway"
value = aws_route_table.public.id
}
output "private_route_table_id" {
description = "ID of the isolated private route table"
value = aws_route_table.private.id
}
Return to the root module directory:
cd ../..
pwd
Our child module is completely self-contained and ready for consumption!
🔌 Step 3: Consuming the Child Module in the Root Project
Now let’s configure the root module to consume our new modules/vpc package.
1. Root versions.tf
Create versions.tf:
# versions.tf
terraform {
required_version = ">= 1.8.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
}
2. Root providers.tf
Create providers.tf:
# providers.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = local.common_tags
}
}
3. Root variables.tf
Create variables.tf:
# variables.tf
variable "aws_region" {
description = "AWS region for deployment"
type = string
default = "us-east-1"
}
variable "environment" {
description = "Target deployment tier"
type = string
default = "dev"
}
variable "project_name" {
description = "Project code identifier"
type = string
default = "app-network"
}
4. Root locals.tf
Create locals.tf:
# locals.tf
locals {
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "OpenTofu"
Lab = "202-Reusable-Modules"
}
}
5. Root main.tf: Calling the Child Module
Create main.tf. Notice how clean and expressive our root configuration is:
# main.tf
# 1. Dynamically discover availability zones
data "aws_availability_zones" "available" {
state = "available"
}
# 2. Invoke our Reusable VPC Child Module
module "network" {
source = "./modules/vpc" # << Local module path!
project_name = var.project_name
environment = var.environment
vpc_cidr = "10.50.0.0/16"
availability_zones = slice(data.aws_availability_zones.available.names, 0, 2)
}
Look at that:
- We set
source = "./modules/vpc". OpenTofu loads the module directly from our local filesystem. - We passed custom parameters:
vpc_cidr = "10.50.0.0/16". - We used OpenTofu’s
slice()function to take the first two available AZs in whatever region the root provider targets.
6. Root outputs.tf: Exporting Module Data
Create outputs.tf:
# outputs.tf
output "vpc_id" {
description = "The ID of the custom VPC created by the module"
value = module.network.vpc_id
}
output "public_subnets" {
description = "Public subnet IDs returned by the module"
value = module.network.public_subnet_ids
}
output "private_subnets" {
description = "Private subnet IDs returned by the module"
value = module.network.private_subnet_ids
}
⚙️ Step 4: The Module Compilation & Symlink Engine
Whenever you add, modify the source of, or remove a module block, you must run tofu init.
Run tofu init:
tofu init
Look closely at the terminal output:
Initializing the backend...
Initializing modules...
- network in modules/vpc
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.50"...
- Installing hashicorp/aws v5.50.0...
...
OpenTofu has been successfully initialized!
What did tofu init actually do?
Inspect the hidden .terraform/modules directory:
cat .terraform/modules/modules.json
Output:
{"Modules":[{"Key":"","Source":"","Dir":"."},{"Key":"network","Source":"./modules/vpc","Dir":"modules/vpc"}]}
OpenTofu registered our child module network and mapped it to ./modules/vpc.
If you do not run tofu init after adding a module block, OpenTofu will halt with:
Error: Module not installed. Run "tofu init" to install all modules.
🚀 Step 5: Planning and Deployment
Validate the configuration:
tofu fmt -recursive
tofu validate
Output:
Success! The configuration is valid.
Now generate and inspect the execution plan:
tofu plan
Look at the resource addresses in the plan:
OpenTofu will perform the following actions:
# module.network.aws_internet_gateway.this will be created
+ resource "aws_internet_gateway" "this" { ... }
# module.network.aws_route_table.private will be created
+ resource "aws_route_table" "private" { ... }
# module.network.aws_route_table.public will be created
+ resource "aws_route_table" "public" { ... }
# module.network.aws_subnet.private_1 will be created
+ resource "aws_subnet" "private_1" {
+ cidr_block = "10.50.10.0/24"
...
}
# module.network.aws_subnet.public_1 will be created
+ resource "aws_subnet" "public_1" {
+ cidr_block = "10.50.1.0/24"
...
}
# module.network.aws_vpc.this will be created
+ resource "aws_vpc" "this" {
+ cidr_block = "10.50.0.0/16"
...
}
Plan: 10 to add, 0 to change, 0 to destroy.
Notice the prefix: module.network.<resource_type>.<name>.
OpenTofu namespaces every single resource inside the module. This guarantees that resources created by module.network will never collide with resources created by any other module!
Apply the configuration:
tofu apply -auto-approve
Inspect the outputs:
tofu output
Output:
private_subnets = [
"subnet-0123456789abcdef0",
"subnet-0123456789abcdef1",
]
public_subnets = [
"subnet-0123456789abcdef2",
"subnet-0123456789abcdef3",
]
vpc_id = "vpc-0123456789abcdef0"
🔬 Step 6: Verifying State & Encapsulation
Let’s verify how OpenTofu tracks module resources in state:
tofu state list
Output:
module.network.aws_internet_gateway.this
module.network.aws_route_table.private
module.network.aws_route_table.public
module.network.aws_route_table_association.private_1
module.network.aws_route_table_association.private_2
module.network.aws_route_table_association.public_1
module.network.aws_route_table_association.public_2
module.network.aws_subnet.private_1
module.network.aws_subnet.private_2
module.network.aws_subnet.public_1
module.network.aws_subnet.public_2
module.network.aws_vpc.this
Proving Encapsulation
Try to inspect the child module’s internal subnet from the root module using the OpenTofu console:
echo "module.network.public_subnet_ids" | tofu console
Result: Returns the list of subnet IDs!
Now try querying the child module’s internal resource directly:
echo "module.network.aws_subnet.public_1.id" | tofu console
Result:
Error: Unsupported attribute
This object does not have an attribute named "aws_subnet".
This proves that our encapsulation contract holds: the caller cannot reach into the module’s internals; it can only consume what is explicitly exported via outputs.tf.
🛠️ Common Troubleshooting & Gotchas
1. Error: Module not installed
- Cause: Creating a new
moduleblock or changingsource, then immediately typingtofu plan. - Fix: Run
tofu init. OpenTofu must parse the module source and update.terraform/modules/modules.json.
2. Sourcing Local Modules with Missing ./
- Bad Syntax:
source = "modules/vpc" - Correct Syntax:
source = "./modules/vpc" - Explanation: OpenTofu uses leading
./or../to distinguish local filesystem paths from remote Terraform/OpenTofu Registry packages.
3. Hardcoding Values Inside Child Modules
- Mistake: Hardcoding
cidr_block = "10.0.1.0/24"insidemodules/vpc/main.tf. - Consequence: If a team attempts to instantiate two VPC modules in the same AWS account or peering group, their subnets will collide. Always derive subnets using functions like
cidrsubnet(var.vpc_cidr, ...).
🧹 Step 7: Clean Teardown
Tear down all module-created resources:
tofu destroy -auto-approve
Output:
module.network.aws_route_table_association.public_1: Destroying...
module.network.aws_route_table_association.public_2: Destroying...
...
module.network.aws_vpc.this: Destroying...
Destroy complete! Resources: 10 destroyed.
Verify that AWS contains zero lingering resources:
aws ec2 describe-vpcs --filters "Name=tag:Project,Values=app-network" --query "Vpcs[].VpcId" --output text
(Returns empty).
🏋️ Hands-On Challenge
Before moving to Lab 203:
- Inside
modules/vpc/main.tf, add two Database Subnets (database_1anddatabase_2) placed in AZ-A and AZ-B usingcidrsubnet(var.vpc_cidr, 8, 100)andcidrsubnet(var.vpc_cidr, 8, 200). - Associate them with the private route table.
- In
modules/vpc/outputs.tf, export a new output:database_subnet_ids. - In your root
outputs.tf, exposeoutput "database_subnets" { value = module.network.database_subnet_ids }. - Run
tofu planto verify that your module interface seamlessly expands without breaking existing public/private contracts.
📖 Summary & Key Takeaways
In this lab, you built the foundation for reusable enterprise cloud engineering:
- Root vs. Child Modules: The root module manages provider configuration, environments, and backends; child modules are reusable, parameterized blueprints.
- Encapsulation Contract: Inputs are declared in child
variables.tf. Outputs are exposed in childoutputs.tf. Internal child resources remain private and inaccessible to the root. - Avoid God Modules: Modules should follow the Single Responsibility Principle. Build focused modules for networking, security, and compute rather than one gigantic wrapper.
- Local Sourcing: Sourcing via
./modules/vpcallows you to develop and iterate on modules within a monorepo before publishing to a registry. - Initialization Requirement: Any change to module sources requires running
tofu initto update OpenTofu’s internal module registry.
🚀 What’s Next? (Lab 203)
Now that we have a reusable networking module, how do we compose a complete architecture?
In [AWS OpenTofu #203: Compose Infrastructure Modules], we will:
- Build a second module:
modules/security-groups. - Build a third module:
modules/ec2. - Chain modules together by passing outputs from the VPC module into the Security Group module, and outputs from both into the Compute module.
- Master implicit dependency resolution and understand when
depends_onis actually necessary.