Table of Contents
Why Terraform Modules Matter
In Terraform, modules are the main way to:
- Reuse infrastructure code
- Keep configurations organized and readable
- Enforce standards and best practices
- Share patterns across teams and projects
You’ve already seen basic Terraform in previous chapters. Here we focus on how to structure and use modules effectively, not on re‑explaining core Terraform syntax.
Think of a module as a function in a programming language: it has inputs, does some work, and returns outputs.
- Root module: the configuration in the current working directory (where you run
terraform apply). - Child module: any module called from another module (local directory or remote source).
Basic Module Structure
A typical module is just a folder with some Terraform files:
main.tf— main resourcesvariables.tf— input variable definitionsoutputs.tf— output valuesversions.tf(optional but recommended) — required providers / Terraform version
Example layout:
.
├── main.tf # root module
└── modules
└── network
├── main.tf
├── variables.tf
└── outputs.tf
Inside modules/network, you might have:
# modules/network/variables.tf
variable "vpc_cidr" {
type = string
description = "CIDR block for the VPC"
}
variable "environment" {
type = string
description = "Environment name (dev, staging, prod)"
}# modules/network/main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
tags = {
Name = "vpc-${var.environment}"
Environment = var.environment
}
}# modules/network/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
description = "The ID of the created VPC"
}
The module directory name (network) is not special to Terraform, but is used when you refer to it from the root module.
Calling a Local Module
From your root module:
# main.tf (root)
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
environment = "dev"
}
output "network_vpc_id" {
value = module.network.vpc_id
}Key elements:
module "network"— defines a module block namednetworksource— where the module lives- Local path:
./modules/network - Or a remote source (Git, registry, etc.)
- Other arguments map to the module’s
variableblocks. - You access outputs via
module.<name>.<output_name>.
Module Inputs: Variables
Inside a module, inputs are defined using variable blocks:
variable "instance_type" {
type = string
description = "EC2 instance type"
default = "t3.micro"
}Good practices for variables in modules:
- Always include
type(e.g.string,number,bool,list(string),map(string)). - Add a
descriptionfor clarity and documentation. - Use
defaultsparingly: modules should be configurable, but not full of hidden magic.
You pass values from the calling module:
module "web_server" {
source = "./modules/web_server"
instance_type = "t3.small"
}Complex Input Types
Modules often take complex structures:
variable "subnets" {
type = map(object({
cidr_block = string
az = string
}))
}
# Caller
module "network" {
source = "./modules/network"
subnets = {
public_1 = {
cidr_block = "10.0.1.0/24"
az = "eu-west-1a"
}
public_2 = {
cidr_block = "10.0.2.0/24"
az = "eu-west-1b"
}
}
}This makes modules flexible and descriptive, while keeping logic inside the module.
Module Outputs
Outputs expose values from a module to its caller:
# modules/web_server/outputs.tf
output "public_ip" {
value = aws_instance.this.public_ip
description = "Public IP of the web server"
}The root module can then use:
output "web_server_ip" {
value = module.web_server.public_ip
}Outputs are useful for:
- Passing resource IDs/IPs between modules
- Feeding values into other tools (CI/CD, scripts)
- Keeping sensitive details limited to where they’re needed
Local Modules vs Remote Modules
Terraform can use modules from:
- Local paths:
./modules/network - Git repositories: e.g. GitHub, GitLab
- Terraform Registry: public or private
- Other sources (HTTP, Mercurial, etc.)
Local Modules
Simple for single‑repo projects:
module "db" {
source = "./modules/db"
db_name = "app"
username = "appuser"
}Pros:
- Easy to start
- No external dependencies
- Great during early development
Cons:
- Harder to share across many projects/repos
- Versioning is tied to the repo, not the module itself
Git-Based Modules
Using a Git repo as the source:
module "network" {
source = "git::https://github.com/example/infrastructure-modules.git//aws/network?ref=v1.2.0"
vpc_cidr = "10.1.0.0/16"
environment = "prod"
}Important details:
git::prefix- Repo URL
//aws/network— subdirectory inside repo?ref=v1.2.0— Git ref: tag, branch, or commit
Pros:
- Centralized repo for many modules
- Version control and reviews
Cons:
- Slightly more complex source URLs
- Governance/versioning must be managed carefully
Terraform Registry Modules
Public modules use a simple source syntax:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
}
Format: NAMESPACE/NAME/PROVIDER
terraform initdownloads modules into.terraform/modules.versionuses the same constraint syntax as providers.
You can also run your own private registry (e.g. in Terraform Cloud/Enterprise) with the same syntax.
Designing Good Modules
Modules can be tiny wrappers or large systems. A few design principles help:
Clear Responsibility
A module should do one logical thing:
network— VPC + subnetsapp_server— EC2 instance + security groups3_bucket— S3 bucket with policies
Avoid “god modules” that create everything at once unless you’re building a deliberate top‑level composition module.
Stable Interface
Treat module inputs/outputs as a public API:
- Think before renaming variables or outputs.
- When changing behavior, try to avoid breaking changes.
- Use semantic versioning: bump major version when you break input/output compatibility.
Reasonable Defaults
- Provide defaults for common/simple cases.
- Require inputs for values that must be explicit (e.g.
environment,regionif not global). - Avoid encoding environment‑specific logic directly; keep it configurable with variables.
Inputs for Policy, Not Implementation Detail
Example:
- Good: variable
allowed_cidrscontrolling firewall rules. - Less good: variable
resource_name_prefix_internal_adapterthat exposes a minor naming detail.
Expose what the caller cares about, not every small internal tweak.
Avoid Tight Coupling to a Single Use Case
If you design a module for prod-eu-west-1-only, it’s hard to reuse. Instead:
- Take
environmentas a variable, not hard‑coded. - Take
regionas a variable, or use provider configuration. - Use naming conventions based on variables.
Example:
locals {
name = "${var.project}-${var.environment}"
}
Then use local.name for resource names.
Composition: Modules Calling Modules
Modules can call other modules, creating a layered design:
# modules/app/main.tf
module "network" {
source = "../network"
vpc_cidr = var.vpc_cidr
environment = var.environment
}
module "web_server" {
source = "../web_server"
subnet_id = module.network.public_subnet_id
instance_type = var.instance_type
}This pattern lets you:
- Build small, focused modules
- Combine them into higher‑level “building blocks”
- Reuse both low‑level and high‑level modules
Module Versioning and Locking
With remote modules (Git or registry), you should:
- Pin specific versions (tags or versions)
- Use version constraints for safe upgrades
Examples:
# Registry module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
# Git module pinned to a tag
module "network" {
source = "git::https://github.com/example/infrastructure-modules.git//aws/network?ref=v1.3.2"
}Planning upgrades:
- Update version or ref.
- Run
terraform plan. - Review changes.
- Apply in non‑production first.
Testing and Validating Modules
For reusable modules (especially shared across teams):
- Use
terraform validateto catch basic issues. - Add example usages in an
examples/directory. - Consider automated tests (e.g.
terratestin Go,kitchen-terraform, or simple shell scripts that runplanand assert no changes).
A simple pattern:
- A minimal example (
examples/minimal) using only required inputs. - A complete example (
examples/complete) using most options.
Organizing Modules in a Repository
Common layouts:
Monorepo with Shared Modules
.
├── envs
│ ├── dev
│ │ └── main.tf
│ ├── staging
│ │ └── main.tf
│ └── prod
│ └── main.tf
└── modules
├── network
├── web_server
└── database- Environments share the same
modules/. - Easy local development.
- Versioning typically done via Git branches/tags per environment or per release.
Dedicated Module Repos
Each major module (or module set) in its own repository:
infra-modules-networkinfra-modules-appinfra-modules-database
Consumers reference them with Git or a registry. Better for:
- Multiple teams
- Strict versioning and ownership
- Publishing to a private registry
Common Pitfalls with Modules
- Hard‑coding values inside modules that should be configurable.
- Circular dependencies: module A depends on module B’s outputs and vice versa.
- Treating modules as “one‑off” — if it’s only used once and has no abstraction benefit, it may not be worth modularizing yet.
- Refactoring an existing root config into modules without planning state changes; resource renames or move operations may be required.
When to Create a Module
Use a module when:
- The pattern is reused in multiple places (or will be).
- You want a consistent implementation of policies/standards.
- You have a complex piece of infrastructure that benefits from a clean interface.
Keep things simple at first:
- Start with a flat root configuration.
- When duplication appears or things grow too large, extract a module.
- Gradually improve and version that module as it stabilizes.
This aligns with good DevOps practice: iterate, refactor, and automate only when it adds real value.