Building Real Infrastructure With Modules
A module is easiest to appreciate when you see several of them working together. Instead of one root module containing every resource, you can give major infrastructure components clear ownership and connect them through explicit inputs and outputs.
This example builds a small AWS architecture with three modules: networking, compute, and storage. The infrastructure is intentionally modest; the goal is to demonstrate architecture rather than create a production-sized environment.
The Architecture
The configuration will look like this:
infrastructure/
βββ main.tf
βββ variables.tf
βββ outputs.tf
βββ providers.tf
βββ modules/
βββ network/
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
βββ compute/
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
βββ storage/
βββ main.tf
βββ variables.tf
βββ outputs.tfThe root module will compose the system:
flowchart TD
R[Root Module]
R --> N[Network Module]
R --> C[Compute Module]
R --> S[Storage Module]
N -->|subnet ID| C
N -->|network ID| S
The network module owns the network.
The compute module owns the EC2 instance.
The storage module owns an S3 bucket.
The root module decides how these components fit together.
Configure The Provider
Create providers.tf:
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}Create variables.tf:
variable "aws_region" {
description = "AWS region used by this example."
type = string
default = "us-east-1"
}This example uses a normal AWS provider configuration in the root module. Provider behavior across modules is covered in detail later.
Build The Network Module
Create modules/network/variables.tf:
variable "name" {
description = "Name prefix for the network resources."
type = string
}
variable "network_cidr" {
description = "CIDR block for the VPC."
type = string
}
variable "subnet_cidr" {
description = "CIDR block for the subnet."
type = string
}Create modules/network/main.tf:
resource "aws_vpc" "this" {
cidr_block = var.network_cidr
tags = {
Name = var.name
}
}
resource "aws_subnet" "this" {
vpc_id = aws_vpc.this.id
cidr_block = var.subnet_cidr
tags = {
Name = "${var.name}-subnet"
}
}Create modules/network/outputs.tf:
output "vpc_id" {
description = "ID of the VPC."
value = aws_vpc.this.id
}
output "subnet_id" {
description = "ID of the subnet."
value = aws_subnet.this.id
}The module has a simple interface:
Inputs:
name
network_cidr
subnet_cidr
Outputs:
vpc_id
subnet_idThe caller does not need to know how the VPC and subnet are implemented.
Build The Compute Module
Create modules/compute/variables.tf:
variable "name" {
description = "Name of the instance."
type = string
}
variable "subnet_id" {
description = "Subnet where the instance is created."
type = string
}
variable "instance_type" {
description = "EC2 instance type."
type = string
default = "t3.micro"
}
variable "ami_id" {
description = "AMI used by the instance."
type = string
}Create modules/compute/main.tf:
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
tags = {
Name = var.name
}
}Create modules/compute/outputs.tf:
output "instance_id" {
description = "ID of the EC2 instance."
value = aws_instance.this.id
}
output "private_ip" {
description = "Private IP address of the instance."
value = aws_instance.this.private_ip
}Notice what this module does not know.
It does not know how the subnet was created.
It simply receives a subnet ID.
That is exactly what a useful module boundary looks like.
Build The Storage Module
Create modules/storage/variables.tf:
variable "bucket_name" {
description = "Name of the S3 bucket."
type = string
}Create modules/storage/main.tf:
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
}Create modules/storage/outputs.tf:
output "bucket_name" {
description = "Name of the S3 bucket."
value = aws_s3_bucket.this.bucket
}
output "bucket_arn" {
description = "ARN of the S3 bucket."
value = aws_s3_bucket.this.arn
}Again, the module has one clear responsibility.
Compose The Modules
Now create the root main.tf:
module "network" {
source = "./modules/network"
name = "devstuffs-example"
network_cidr = "10.0.0.0/16"
subnet_cidr = "10.0.1.0/24"
}
module "compute" {
source = "./modules/compute"
name = "devstuffs-example-server"
subnet_id = module.network.subnet_id
ami_id = "ami-0123456789abcdef0"
}
module "storage" {
source = "./modules/storage"
bucket_name = "devstuffs-terraform-module-example"
}The AMI ID above is deliberately an example value and may not exist in your selected AWS region. Before applying this example, replace it with an AMI ID valid for your region and desired operating system.
The important architectural relationship is:
subnet_id = module.network.subnet_idThe compute module receives information from the network module.
Terraform can therefore understand:
network
β
β subnet_id
βΌ
computeThe root module is composing the two components.
Expose Useful Root Outputs
Create outputs.tf:
output "network_id" {
value = module.network.vpc_id
}
output "instance_id" {
value = module.compute.instance_id
}
output "instance_private_ip" {
value = module.compute.private_ip
}
output "bucket_name" {
value = module.storage.bucket_name
}The root module now presents a small set of useful results.
It does not expose every resource attribute from every module.
That is deliberate.
Understand The Dependency Graph
The module relationship can be represented as:
flowchart LR
N[Network Module] -->|vpc_id / subnet_id| C[Compute Module]
N -->|vpc_id| S[Storage Module]
In the actual configuration, the compute module depends on the network output because of this reference:
subnet_id = module.network.subnet_idTerraform does not need you to manually describe that dependency.
This is preferable to adding:
depends_on = [module.network]when there is already a real value dependency.
Initialize The Configuration
Create the project directory and files exactly as shown, then run from the root directory:
terraform initInspect the configuration:
terraform validateThen create a plan:
terraform planIf the AMI ID is valid for your selected region and the AWS credentials are configured, Terraform should show the resources that the modules will create.
Apply only when you are ready:
terraform applyAfter the apply completes:
terraform outputYou should see the outputs exposed by the root module.
Warning
This example creates real AWS resources and can incur charges. Review the plan carefully and use an AWS account and region appropriate for experimentation.
What Belongs In The Root Module?
The root module is a good place for composition-level decisions.
In this example, it decides:
- which modules to use
- which network values to pass
- which compute values to pass
- which module outputs to expose
- which environment-level values to configure
The root module does not implement the VPC resource itself.
It does not implement the EC2 instance itself.
It does not implement the S3 bucket itself.
Those responsibilities belong to their modules.
This separation becomes increasingly useful as infrastructure grows.
What Belongs Inside A Module?
A module should own the implementation of the component it represents.
The network module owns:
VPC
SubnetThe compute module owns:
EC2 instanceThe storage module owns:
S3 bucketIf the network implementation later changes from one subnet to multiple subnets, the caller should ideally continue consuming a meaningful network interface rather than depending on individual resources inside the network module.
That is the architectural payoff.
Keep Environment Configuration Outside The Modules
Suppose the same network module is used by two environments.
The module can remain generic:
module "network" {
source = "./modules/network"
name = "application"
network_cidr = var.network_cidr
subnet_cidr = var.subnet_cidr
}The environment-specific values belong to the caller.
This keeps the module reusable while allowing different callers to make different configuration choices.
The module provides the implementation.
The root module provides the context.
Do Not Make The Example Bigger Just To Make It Look Real
It would be easy to add load balancers, security groups, NAT gateways, autoscaling, databases, monitoring, and many other resources.
That would make the configuration look more impressive but would weaken the lesson.
The useful architecture is already visible:
Root Module
β
βββββββββββββββΌββββββββββββββ
βΌ βΌ βΌ
Network Compute Storage
β β β
ββββββββ¬βββββββ β
β β
βΌ βΌ
Infrastructure InfrastructureThe point of modules is not to maximize abstraction.
It is to establish boundaries that make infrastructure easier to understand and reuse.
Clean Up
If you applied the configuration, destroy the resources when you are finished:
terraform destroyReview the destruction plan and confirm it.
Afterward, if you no longer need the local Terraform working directory:
rm -rf .terraform
rm -f .terraform.lock.hclDo not remove the module source files unless you no longer need the project.
The most important lesson from this example is that the root module is a composition layer. It connects meaningful infrastructure components through their interfaces while leaving implementation details inside those components.
That is the point where Terraform configuration starts becoming architecture.