Learn Terraform - Creating & Managing Reusable Modules
Episode 9 of 21

Learn Terraform - Creating & Managing Reusable Modules

Understand why modules are the foundation of IaC at enterprise scale. Learn the standard module structure, creating a reusable VPC module, calling local and remote modules, up to version pinning and its best practices.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After discussing Advanced State Manipulation in episode 8 — import, moved, and the terraform state command family — in this episode we move one abstraction level higher: Creating & Managing Reusable Modules.

Let's be honest for a moment. If your Terraform project is left to grow without rules, sooner or later main.tf will turn into a monster with thousands of lines managing VPC, subnets, security groups, instances, databases, load balancers, all the way to S3 buckets — everything in one file. Every time you want to add a new instance, you copy dozens of lines then change a few names. Change in one place, forget in another. In software engineering this is called copy-paste code, and we all know where it leads: inconsistency, drift between resources, and hard-to-trace errors.

Think of it like a restaurant chain. Imagine if every chef wrote recipes in their own format — one on a scrap of paper, one in a notebook, another one memorized. The result: the same menu item can taste different at every branch. The solution is recipe standardization: one standard format, one source of truth, reusable across all branches. In Terraform, that "standard recipe" is called a module.

In this episode you'll learn why modules matter, the standard structure of a module, how to create your own module, how to call local and remote modules from the Terraform Registry, and how to pin module versions to keep teams in control. This is the skill that differentiates config that "happens to work" from config that's "production-ready".

Main Discussion

Why Modules? More Than Just Splitting Files

A module is a container for a set of resources used together. It works like a function in a programming language: receives input (variables), does internal work (resources), then returns results (outputs). The caller doesn't need to know the implementation details — just "what can be configured" and "what you get".

From this come five main advantages that make modules an industry standard:

  1. Reusability (DRY) — the same infrastructure is written once, used in many places. A VPC module is written once, used for dev, staging, and prod.
  2. Encapsulation — resource details are hidden inside the module. Users just set input variables, rather than understanding how every resource inside works.
  3. Consistency & standardization — all environments are built with exactly the same configuration, eliminating each engineer's "personal style".
  4. Ease of review — code review becomes concise: "why did you change the port in the vpc module?" instead of reading a thousand-line diff.
  5. Testability — small, focused modules can be tested one by one with Terratest (we discuss this in episode 11).

Standard Terraform Module Structure

There's no hard rule that a module must have specific file names — Terraform just reads all .tf files in that directory. But the community has agreed on the following convention so any module is easy to understand:

modules/vpc/ — standard module structure
modules/
└── vpc/
    ├── main.tf          # Main resource definitions
    ├── variables.tf     # Input variable declarations
    ├── outputs.tf       # Values exposed to the caller
    ├── versions.tf      # Terraform & provider constraints
    └── README.md        # Usage documentation
FileFunction
main.tfThe main resources managed by the module (at least one .tf file is required).
variables.tfInput declarations — type, description, default, up to validation.
outputs.tfValues the caller can consume, e.g. vpc_id, subnet_ids.
versions.tfThe terraform { required_version, required_providers } block to lock versions.
README.mdDocumentation: when to use the module, available variables, usage examples.

Note

File names are just a convention — Terraform merges all .tf files in one directory into a single configuration. What's truly required is at least one resource (or pure data/output) inside the module. The five-file convention above makes modules consistent and easy to review, especially if they'll later be published to a module registry.

Creating Our First VPC Module

The best way to understand modules is to create one yourself. Let's build a reusable vpc module: accepting a name, CIDR, a list of public subnet CIDRs, and availability zones as inputs; managing the VPC, subnets, internet gateway, and route table; then exposing their IDs as outputs.

First, lock the versions in versions.tf:

modules/vpc/versions.tf
terraform {
  required_version = ">= 1.5"
 
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

Then the input declarations in variables.tf. Notice that every variable has a description and type — this isn't just style, but living documentation for the caller:

modules/vpc/variables.tf
variable "name" {
  description = "Name for the VPC and the resources inside it"
  type        = string
}
 
variable "cidr" {
  description = "Main CIDR block of the VPC"
  type        = string
  default     = "10.0.0.0/16"
}
 
variable "azs" {
  description = "List of availability zones for subnets"
  type        = list(string)
}
 
variable "public_subnet_cidrs" {
  description = "List of CIDR blocks for public subnets"
  type        = list(string)
}
 
variable "tags" {
  description = "Additional tags merged into all resources"
  type        = map(string)
  default     = {}
}

Now main.tf — the heart of the module. Notice the merge(...) pattern to combine built-in tags with caller tags, and the use of count to create as many subnets as there are items in the list:

modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
 
  tags = merge(
    { Name = var.name },
    var.tags,
  )
}
 
resource "aws_subnet" "public" {
  count = length(var.public_subnet_cidrs)
 
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.azs[count.index]
 
  map_public_ip_on_launch = true
 
  tags = merge(
    { Name = "${var.name}-public-${count.index + 1}" },
    var.tags,
  )
}
 
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
 
  tags = merge(
    { Name = "${var.name}-igw" },
    var.tags,
  )
}
 
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 = merge(
    { Name = "${var.name}-public" },
    var.tags,
  )
}
 
resource "aws_route_table_association" "public" {
  count = length(var.public_subnet_cidrs)
 
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

Finally, export the useful values in outputs.tf. Remember: only what's declared here can be accessed by the caller:

modules/vpc/outputs.tf
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.this.id
}
 
output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}
 
output "internet_gateway_id" {
  description = "ID of the Internet Gateway"
  value       = aws_internet_gateway.this.id
}

Tip

Always write description on variables and outputs. When a module is used by many teams, these descriptions are what appear when someone runs terraform plan or reads the documentation — without descriptions, your module feels like a black box that must be dismantled to understand.

Calling the Module from the Root Configuration

The module above isn't useful until it's called. In the root directory, we declare the module with a module block — its source and inputs:

main.tf
module "vpc" {
  source = "./modules/vpc"
 
  name = "app-vpc"
  cidr = "10.0.0.0/16"
 
  azs                 = ["ap-southeast-1a", "ap-southeast-1b"]
  public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
 
  tags = {
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}
 
resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  subnet_id     = module.vpc.public_subnet_ids[0]
 
  tags = {
    Name = "web-server"
  }
}

Two important things above:

  1. Inputs are passed as argumentsname, cidr, azs, etc. flow through the module's variable blocks.
  2. Outputs are consumed with module.<name>.<output>module.vpc.public_subnet_ids[0] takes the first subnet from the module. This is what forms the implicit dependency automatically: Terraform knows the instance depends on the VPC module, without needing depends_on.

After writing the module block, run terraform init. Local modules don't need downloading, but Terraform will make sure the structure is valid:

terraform init
terraform init
terraform init output
Initializing modules...
- vpc in modules/vpc
 
Initializing the backend...
 
Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 5.0.0"...
- Installing hashicorp/aws v5.61.0...
- Installed hashicorp/aws v5.61.0 (signed by HashiCorp)
 
Terraform has been successfully initialized!

Note

The terraform get command is the historical predecessor of terraform init, specifically for downloading modules. In modern Terraform, terraform init already covers module downloading (local and remote) along with provider installation and backend initialization. Use terraform init as the standard command; terraform get is only relevant if you want to just download modules without touching providers/backends.

Local vs Remote Modules

There are two categories of module sources you'll encounter every day:

  • Local modulesource = "./modules/vpc". The module lives in the same repo, usually in a modules/ folder. Suitable for internal code not yet shared across repos. Relative paths must start with ./ or ../ (writing source = "modules/vpc" without ./ will be treated as a registry name).
  • Remote module — downloaded from an external source: Terraform Registry, Git, or object storage. Suitable for components used across teams/projects, like those developed by a platform team.
module "vpc" {
  source = "./modules/vpc"
 
  name = "app-vpc"
  cidr = "10.0.0.0/16"
 
  public_subnet_cidrs = ["10.0.1.0/24"]
  azs                 = ["ap-southeast-1a"]
}

Notice the details in the examples above:

  • The registry source is written in the <namespace>/<name>/<provider> format — here terraform-aws-modules/vpc/aws, the most widely used community VPC module in the world.
  • The Git source uses git:: followed by the URL, then // to point to a subdirectory within the repo, and ?ref= to specify a tag/branch/commit. This is a common pattern for private module repositories.

When terraform init runs with a remote module, the output will show the installation process from the registry:

Remote module init output
Initializing modules...
Downloading terraform-aws-modules/vpc/aws 5.8.1 for vpc...
- vpc in .terraform/modules/vpc
 
Initializing the backend...

Module Version Pinning

Just like application dependencies, remote modules must be version-pinned with the version argument. Without pinning, terraform init will always pull the latest version and your infrastructure can change behavior without you realizing it. The writing rules are the same as provider version constraints:

ConstraintMeaning
version = ">= 1.0"Any version from 1.0 upward (too loose)
version = "~> 1.2"Version 1.2.x — patches allowed, doesn't pass the minor
version = ">= 1.0, < 2.0"Range between 1.0 and 2.0
version = "1.2.5" / = 1.2.5Only exactly 1.2.5 (most strict)

Warning

Local modules (./modules/...) don't support the version argument — they always follow the code in that directory, which is naturally versioned by git. For remote modules, it's mandatory to include version. For Git sources, use a ref pointing to a release tag (like ?ref=v1.2.0), not a branch like main, so changes on the branch don't silently enter production infrastructure.

Module Building Best Practices

Writing a module that "can be used" is easy; writing a module that other teams are comfortable using takes discipline. Several practices proven in the field:

  1. Start from real needs. Don't immediately create a super-generic module with hundreds of variables. Refactor into a module after the same pattern is used at least twice.
  2. Use correct types and defaults. Every variable must have a type and description; give a default only if there's a generally sensible value.
  3. Export only the outputs truly needed. Excessive outputs add to the module's API surface and are hard to maintain.
  4. Provide usage examples in an examples/ directory — this doubles as a test fixture (episode 11).
  5. Don't put providers/credentials inside the module. Provider configuration is the caller's concern, not the module's. A module may only store required_providers in versions.tf.
  6. Use references between resources, not hardcoding. Always refer to aws_vpc.this.id rather than guessing the same ID.
  7. Version from the start. Even while local, get used to thinking about change compatibility — breaking changes must come with a major version.
  8. Include a README.md with a variables and outputs table — or use a generator like terraform-docs so the documentation stays in sync with the code.

Common Pitfalls

1. Forgetting to run terraform init after adding a new module

The most classic error when just learning: Error: Module not installed. This module is not yet installed. Run "terraform init" to install all modules required by this configuration. The solution is always the same — run terraform init.

2. Local source path without ./ or ../

source = "modules/vpc" will be interpreted as a registry module name, not a local path, and ends in an error like Error: Failed to query available provider packages or Module not found. Always write ./modules/vpc.

3. Module without versions.tf

Without provider constraints, two modules in one project can demand different provider versions, and init will fail with a conflict. Always declare required_providers and required_version in every module.

4. Placing provider configuration inside the module

A provider "aws" { region = "ap-southeast-1" } block inside a module isn't portable and triggers the "provider configurations are only allowed in the root module" warning. Region/credential matters are the caller's right.

5. Consuming outputs that aren't declared

module.vpc.subnet_ids will error with Unsupported attribute if the module doesn't export subnet_ids (in our example it's named public_subnet_ids). Check the module's outputs.tf before consuming.

6. Monolithic modules

One module managing VPC, EC2, RDS, and IAM all at once is an anti-pattern — it becomes a "monster" again, just relocated. Split it into small, focused modules, according to resource groups that are always used together.

Conclusion

In this episode 9 you've mastered the foundations of Terraform modularity: understanding why modules matter as a way to standardize reusable, battle-tested infrastructure code; getting to know the standard module structure (main.tf, variables.tf, outputs.tf, versions.tf, README.md); creating a complete VPC module with inputs, resources, and outputs; calling local and remote modules (Registry & Git); doing version pinning; as well as best practices and common mistakes often encountered by new teams.

Key takeaways to bring home:

  • A module is a "function" for infrastructure: input → resource → output.
  • Use ./ for local modules, <namespace>/<name>/<provider> for the Registry, and git::...//subdir?ref= for Git.
  • Always pin remote module versions, and document every module's variables/outputs.
  • Refactor into modules when patterns repeat, not at the start.

Modularized code does solve the duplication problem, but leaves one big question: how do you manage dev, staging, and prod simultaneously without colliding with each other? This is the topic that's often a source of conflict between teams.

In the next episode 10 we'll discuss Workspaces vs Directory-Based Multi-Environment — understanding the terraform workspace concept, its limitations that often trip people up, to the environment-based directory structure that's the enterprise standard. Stay excited!