Organize Terraform with Modules
Learn to organize Terraform code with modules. This tutorial covers the core concepts, step-by-step implementation, and best practices for structuring your infrastructure as reusable, maintainable modules.
Focus: organize Terraform with modules
You've written Terraform configs that work — maybe even a few that are starting to feel repetitive. Every new environment means copy-pasting resource blocks, tweaking variable names, and hoping you didn't miss a spot. That's the maintenance trap: your infrastructure code is becoming a tangled web of near-duplicates, and every change feels risky. The solution is modules — the fundamental building block for organizing Terraform code into reusable, testable, and maintainable components. This lesson shows you how to break down your infrastructure into modules, use them across environments, and keep your codebase clean as it grows.
The problem this lesson solves
Imagine your team manages three environments: dev, staging, and prod. Each environment needs a VPC, an EC2 instance, and a security group. Without modules, you have three copies of nearly identical code, each with subtle differences. A security group rule change means editing the same block in three places — and missing one is a silent production incident waiting to happen.
This is the copy-paste problem in infrastructure-as-code. It leads to:
- Drift: environments slowly diverge until they behave differently.
- High maintenance: every change requires find-and-replace across files.
- Error-prone reviews: reviewers have to diff similar blocks to spot real differences.
- Low reuse: you can't easily share infrastructure logic across projects or teams.
Modules solve this by letting you define infrastructure once and call it many times with different inputs. Instead of copy-pasting, you import the module and pass variables. This is the same reason functions exist in programming languages — and modules are Terraform's version of functions.
Why now? As your infrastructure grows past a handful of resources, the cost of not using modules compounds quickly. Every duplicated block is a future bug. This lesson gives you the tools to stop the bleeding before your codebase becomes unmanageable.
Core concept / mental model
Think of a Terraform module as a blueprint for a piece of infrastructure. You define the blueprint once (the module's code), then you can stamp out as many copies as you need, each customized by inputs (variables) and each exposing outputs (return values).
In programming terms:
- Module directory = a function definition
- Variables = function parameters
- Outputs = function return values
- Calling the module = invoking the function with arguments
Terraform treats every directory with .tf files as a module. The directory where you run terraform commands is your root module. Any module you call from there is a child module. This is a core insight: you've been writing modules all along — the root module is just the one you execute.
Modules bring three key superpowers:
- Reusability — write once, use in many places (multiple environments, projects, regions).
- Abstraction — hide complex details behind a simple interface. Callers don't need to know the inner workings; they just pass inputs and get outputs.
- Consistency — everyone uses the same tested, vetted configuration, reducing drift and security misconfigurations.
A module has a clear boundary: it defines what it needs (variables), what it creates (resources), and what it exposes (outputs). This contract makes modules composable — you can build small modules and combine them into larger ones.
Pro tip: Start with simple modules for small, generic components (like an EC2 instance or a security group). As you gain experience, you'll create more complex modules that wrap multiple resources, such as a full "web app" module combining load balancer, instance, and database.
How it works step by step
Creating and using a module follows a clear, repeatable pattern:
1. Design the module interface
Decide what the module needs to know (variables), what it will create (resources), and what callers will need to know afterward (outputs). Keep the interface minimal — expose only what's necessary.
2. Create the module directory
A module is just a directory with .tf files. Convention typically places modules in a modules/ directory, with one subdirectory per module. For example:
project/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
├── ec2_instance/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── vpc/
├── main.tf
├── variables.tf
└── outputs.tf
3. Write the module code
Within the module directory, declare variables, resources, and outputs using standard Terraform syntax. The module is self-contained; it doesn't reference the root module's variables directly — everything comes through its own variable definitions.
4. Call the module from your root configuration
Use a module block to invoke the module, passing variable values. Terraform will read the module's code and incorporate its resources into your configuration.
5. Refactor your existing code
Move existing resource definitions from your root module into appropriate module directories. Replace the old blocks with module calls. This is a learning process — start with one resource type at a time.
6. Init, plan, and apply
Because modules are just code, you need to run terraform init to download any modules from a registry (if you're using remote modules). Then terraform plan and terraform apply work as usual — Terraform will show the module's resources in the plan.
Key insight: Modules are not a separate execution step. They are expanded during planning, so you still see all the underlying resources in
terraform planoutput.
Hands-on walkthrough
Let's build a simple module that creates an EC2 instance. You'll see how variables, resources, and outputs come together.
Step 1: Create the module directory structure
mkdir -p modules/ec2_instance
cd modules/ec2_instance
Step 2: Write the module's variables.tf
variable "ami_id" {
description = "AMI ID for the EC2 instance"
type = string
}
variable "instance_type" {
description = "Instance type (e.g., t3.micro)"
type = string
default = "t3.micro"
}
variable "name" {
description = "Name tag for the instance"
type = string
}
Step 3: Write the module's main.tf
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
tags = {
Name = var.name
}
}
Step 4: Write the module's outputs.tf
output "instance_id" {
description = "ID of the created EC2 instance"
value = aws_instance.this.id
}
output "public_ip" {
description = "Public IP address of the instance"
value = aws_instance.this.public_ip
}
Step 5: Call the module from your root module
Navigate back to your project root and update main.tf to use the module for multiple environments:
provider "aws" {
region = "us-east-1"
}
module "dev_instance" {
source = "./modules/ec2_instance"
ami_id = "ami-0c55b159cbfafe1f0" # example AMI
name = "dev-server"
}
module "prod_instance" {
source = "./modules/ec2_instance"
ami_id = "ami-0c55b159cbfafe1f0" # example AMI
name = "prod-server"
# instance_type defaults to t3.micro for prod as well — you'd probably override this
}
Step 6: Run Terraform
terraform init # initializes the module (for modules from registry)
terraform validate
terraform plan # shows both instances being created
terraform apply # creates the resources
Expected plan output (abridged):
# module.dev_instance.aws_instance.this will be created
+ resource "aws_instance" "this" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t3.micro"
...
}
# module.prod_instance.aws_instance.this will be created
+ resource "aws_instance" "this" {
+ ami = "ami-0c55b159cbfafe1f0"
+ instance_type = "t3.micro"
...
}
Both modules use the same underlying resource definition, but each has its own state, so changes to one don't affect the other. This is the power of modules — you called the same blueprint twice with different inputs.
Compare options / when to choose what
Modules aren't the only way to organize Terraform code. Here's a comparison of common approaches:
| Approach | When to use | Pros | Cons |
|---|---|---|---|
| Single root module | Tiny experiments, one-off scripts | Simple, no overhead | Repeats code, hard to maintain as you scale |
| Modules (local) | Reuse within one project or repo | Versionable with your code, no external dependency | Need to duplicate across repos if used elsewhere |
| Modules (remote registry) | Sharing across teams/projects | Centralized, versioned, easy to consume | Adds external dependency, requires registry setup |
| Terragrunt (wrapper) | Managing complex multi-environment setups | DRY configuration, remote state management | Extra tool to learn, more abstraction |
When to use modules:
- You have identical or near-identical infrastructure across environments (dev, staging, prod).
- You want to abstract complex resource sets (e.g., a full VPC with subnets, route tables, gateways).
- You want to share best practices across a team (a standard
ec2_instancemodule that always applies the right tags, uses IMDSv2, etc.).
When not to use modules:
- A resource is used exactly once and is unlikely to be reused — wrapping it in a module adds indirection without benefit.
- The infrastructure is highly bespoke with little commonality — modules may force awkward parameterization.
Pro tip: A good rule of thumb — if you're about to copy-paste a block of resources for a second time, that's your signal to extract a module.
Troubleshooting & edge cases
"Module not found" or "Failed to download module"
If you're using a remote module (from the Terraform Registry or a Git URL) and forget to run terraform init, you'll get errors about missing modules. Fix: Always run terraform init after adding a new module reference.
Variable not declared in module
If you pass a variable to a module that doesn't declare it, you'll see an error like Unsupported argument. Fix: Check the module's variables.tf and ensure every passed variable is declared.
Output value referencing non-existent resource
If your module's outputs reference resources that don't exist (e.g., aws_instance.this.public_ip when the instance has no public IP), you'll get a null value or an error. Fix: Ensure your output expressions are valid for all possible scenarios, or use try() for optional values.
Module creates resources you didn't expect
Modules can hide complexity — sometimes too well. Review terraform plan carefully to see exactly what resources will be created. If a module has destructive behavior (e.g., deleting and recreating a database), be aware of it before applying.
Version conflicts in module dependencies
When using modules from a registry, different modules may require different provider versions. Terraform will complain about provider version conflicts. Fix: Pin provider versions in your root module and ensure module compatibility.
"Cycle" errors when modules call each other
If module A calls module B and module B calls module A, Terraform throws a cycle error. Fix: Design your module hierarchy as a tree, not a graph — modules should call child modules, not parents.
Pro tip: Use
terraform validatefrequently. It catches many module-related issues (missing variables, invalid references) without needing to connect to the cloud provider.
What you learned & what's next
You've taken a major step toward professional Terraform. You now understand:
- What modules are: reusable, parameterized blueprints for infrastructure.
- Why they matter: they eliminate copy-paste, reduce drift, and promote consistency.
- How to create them: define variables, resources, and outputs in a dedicated directory.
- How to use them: call the module from your root configuration with
moduleblocks. - When to choose modules over a monolithic configuration.
You can now organize code with modules — a skill that will save you hours of maintenance and make your infrastructure code a joy to work with.
What's next: In the next lesson, you'll learn about State in Terraform — how Terraform tracks the resources it manages, why state is critical for collaboration, and how to store it remotely so your team can work together without conflicts. Modules and state go hand-in-hand: modules make your code clean, and state makes your deployments reliable.
Practice recap
Now it's your turn: take an existing Terraform configuration with a repeated resource (like an EC2 instance) and extract it into a local module. Call the module twice with different name values, run terraform plan, and verify both instances appear. Then try adding an output and referencing it in the root module.
Common mistakes
- Not running
terraform initafter adding a module — causes 'Failed to download module' errors. - Passing variables to a module without declaring them in its
variables.tf— triggers 'Unsupported argument'. - Extracting a module too early for a resource used only once — adds needless indirection.
- Ignoring
terraform planoutput and applying blindly — modules can hide destructive resource replacements.
Variations
- Use modules from the Terraform Registry instead of local directories to share across projects.
- Use Terragrunt to manage multiple modules with DRY configuration and remote state.
- Version your local modules with tags and reference them via Git URLs (e.g.,
source = "git::https://...")
Real-world use cases
- A dev team manages dev/staging/prod environments by calling the same VPC module with different CIDR blocks and tags.
- A platform team publishes a hardened EC2 module internally, ensuring all instances get mandatory security tags and IMDSv2.
- A startup uses registry modules from the community to provision Kubernetes clusters, saving weeks of Terraform code.
Key takeaways
- Modules are Terraform's way to write reusable, parameterized infrastructure code, like functions in programming.
- Every Terraform directory is a module — the one you execute is the root module; called modules are child modules.
- Define a module with variables for inputs, resources for creation, and outputs for return values.
- Use modules to eliminate copy-paste, reduce drift, and enforce consistency across environments.
- Always run
terraform initafter adding module references, and reviewterraform planbefore applying. - Compare your options: local modules vs remote modules vs Terragrunt, and choose based on your team's sharing needs.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.