Skip to content

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.tf

The 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_id

The 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_id

The compute module receives information from the network module.

Terraform can therefore understand:

network
   β”‚
   β”‚ subnet_id
   β–Ό
compute

The 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_id

Terraform 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 init

Inspect the configuration:

terraform validate

Then create a plan:

terraform plan

If 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 apply

After the apply completes:

terraform output

You 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
Subnet

The compute module owns:

EC2 instance

The storage module owns:

S3 bucket

If 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       Infrastructure

The 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 destroy

Review the destruction plan and confirm it.

Afterward, if you no longer need the local Terraform working directory:

rm -rf .terraform
rm -f .terraform.lock.hcl

Do 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.

Last updated on