Using Terraform Modules
Learn how to use Terraform modules from local paths and the Terraform Registry, pass inputs, pin versions, run terraform init, and consume module outputs.
What Are Terraform Modules?
A Terraform module is a directory that contains Terraform configuration files. That definition is surprisingly simple, but it is incredibly powerful.
Every Terraform working directory is already a module. The directory where you run terraform init, terraform plan, and terraform apply is called the root module. When the root module calls another module using a module block, that other module is called a child module.
Modules matter because they make infrastructure code reusable. Instead of copying the same VPC, subnets, security groups, and tags into many projects, you can define them once and reuse them with different inputs.
Why Use Modules?
As infrastructure grows, copy-paste becomes dangerous:
- bugs get duplicated
- security fixes are inconsistent
- naming standards drift
- onboarding becomes harder
- reviews get noisier
Modules reduce those problems by standardizing common infrastructure patterns.
Benefits of modules
- Reusability: use the same infrastructure pattern many times.
- Consistency: enforce approved defaults for security, tagging, and naming.
- Simpler root modules: your top-level configuration becomes cleaner and more readable.
- Team productivity: app teams can consume proven modules instead of building everything from scratch.
- Safer upgrades: versioned modules help teams adopt changes deliberately.
The Root Module vs Child Modules
Understanding this distinction is essential.
Root module
The root module is the Terraform directory you run commands in.
Example:
project-root/
main.tf
variables.tf
outputs.tf
If you run Terraform from project-root/, that directory is the root module.
Child module
A child module is any module called by another module.
Example structure:
project-root/
main.tf
modules/
vpc/
main.tf
variables.tf
outputs.tf
If project-root/main.tf contains:
module "network" {
source = "./modules/vpc"
}
then modules/vpc is a child module.
Mental model
- root module = your deployment entry point
- child module = reusable building block called by the root or another module
Using a Module with a module Block
Terraform uses the module block to call a module.
module "network" {
source = "./modules/vpc"
project_name = var.project_name
environment = var.environment
vpc_cidr = var.vpc_cidr
}
Important parts of the module block
module "network"gives the module call a local namesourcetells Terraform where to find the module- the remaining arguments are inputs passed into the child module
This is similar to calling a function in programming:
- the module source is the function definition
- input variables are parameters
- outputs are return values
Module Block Syntax in Detail
Here is a more complete example:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
name = "platform-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
tags = {
Environment = "prod"
ManagedBy = "Terraform"
}
}
Common attributes
| Attribute | Meaning |
|---|---|
source | Where the module lives |
version | Which version of a Registry module to use |
| other arguments | Values for the child module's input variables |
version is used for Registry modules and some remote sources. It is not used with plain local paths.
The Terraform Registry
The Terraform Registry at registry.terraform.io is the central catalog of published Terraform providers and modules.
It includes:
- official HashiCorp modules
- community modules
- partner-maintained modules
Using Registry modules can save huge amounts of time because common infrastructure patterns already exist.
Why the Registry is useful
Instead of writing a VPC module from scratch, you may be able to start from a mature, widely used module that already handles:
- subnets
- route tables
- NAT gateways
- tags
- optional features
- outputs
Important beginner caution
Registry modules are powerful, but do not treat them as magic. Always read:
- the module README
- input variable docs
- outputs
- version history
- examples
Using a module without understanding what it creates is risky.
Using Modules from the Terraform Registry
A Registry module source usually looks like this:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
name = "demo-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
}
The source format often follows:
<namespace>/<name>/<provider>
In this example:
- namespace =
terraform-aws-modules - name =
vpc - provider =
aws
Popular Terraform Modules
Two widely used AWS examples are the AWS VPC module and the AWS EKS module.
AWS VPC module
The VPC module from terraform-aws-modules is popular because it handles a lot of standard networking complexity.
Common use cases include:
- creating public and private subnets
- setting up route tables
- managing NAT gateways
- standardizing VPC outputs
Example:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
name = "app-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
}
AWS EKS module
The EKS module is popular for provisioning Kubernetes clusters on AWS.
It can help manage:
- cluster creation
- node groups
- IAM integrations
- cluster networking inputs
- outputs used by later automation
Example conceptually:
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "20.13.1"
cluster_name = "platform-eks"
cluster_version = "1.31"
subnet_ids = module.vpc.private_subnets
vpc_id = module.vpc.vpc_id
}
You do not need to memorize these examples; the important lesson is that modules let you consume proven infrastructure patterns instead of reinventing everything yourself.
Module Versions and Version Constraints
When using Registry modules, always think about versions.
If you omit a version entirely, Terraform may download the latest available version. That can be risky because new versions may introduce behavior changes or breaking changes.
Version pinning example
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
# inputs omitted
}
This pins the module to one exact version.
Version constraints example
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
}
This allows patch-level upgrades within the compatible 5.8.x series.
Why version control matters
Pinning versions gives you:
- reproducible runs
- safer upgrades
- easier debugging
- clearer change management in pull requests
For beginners and production teams alike, explicit versions are a good habit.
terraform init Downloads Modules
When Terraform sees a module block, it does not automatically know where the module code lives locally. terraform init handles that setup.
terraform init
During initialization, Terraform:
- downloads providers
- downloads referenced modules
- prepares the working directory
- configures the backend if one exists
If you add or change module sources, run terraform init again.
This is why terraform init is often the first command after pulling new Terraform code.
Using a Local Module
Not every module comes from the Registry. Many teams write internal modules and store them in the same repository.
Example:
module "logging_bucket" {
source = "./modules/s3-logging-bucket"
bucket_name = "company-prod-logs"
environment = "prod"
}
Here, the module source is a relative local path.
This is a good pattern when:
- the module is specific to your organization
- you are learning modules
- you want to keep everything in one repository initially
Passing Inputs into Modules
Module inputs are just variables defined inside the child module.
If the child module declares:
variable "environment" {
type = string
}
then the caller can pass:
module "app" {
source = "./modules/app"
environment = "prod"
}
The caller must satisfy required variables and may optionally override defaults.
Accessing Module Outputs
Child modules return values using output blocks. The parent reads them with:
module.<module_name>.<output_name>
Example child output:
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
}
Example parent usage:
module "vpc" {
source = "./modules/vpc"
# inputs omitted
}
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = module.vpc.vpc_id
}
This is how modules connect together cleanly.
Complete Example: VPC Module + App Module
module "network" {
source = "./modules/vpc"
project_name = var.project_name
environment = var.environment
vpc_cidr = var.vpc_cidr
}
module "web_app" {
source = "./modules/web-app"
project_name = var.project_name
environment = var.environment
vpc_id = module.network.vpc_id
subnet_ids = module.network.public_subnet_ids
}
output "application_url" {
value = module.web_app.application_url
}
This demonstrates a common Terraform pattern:
- one module creates shared networking
- another module creates the application stack
- outputs connect them together
Choosing Between Writing Your Own vs Using Registry Modules
There is no single right answer.
Use a Registry module when:
- the problem is common and already well solved
- the module is mature and widely adopted
- you want faster delivery
- you accept the module's opinionated design
Write your own module when:
- your organization has specific standards
- you want a simpler interface than a large public module
- you need custom behavior not covered by existing options
- you want tighter control over implementation details
Often teams use a mix of both.
Best Practices for Using Terraform Modules
- Read the module documentation before using it.
- Pin module versions deliberately.
- Keep your root module simple and readable.
- Use outputs instead of re-creating knowledge in the parent module.
- Prefer proven modules for common infrastructure patterns.
- Do not pass every possible input unless you truly need to customize it.
- Re-run
terraform initafter adding or changing modules.
Common Beginner Mistakes
Using a Registry module without reading the README
This can lead to surprising resource creation, unnecessary cost, or incorrect assumptions.
Forgetting the version
An unpinned module may change later in ways you did not expect.
Passing too many inputs blindly
Start from the minimal required inputs. Add optional settings only when you understand them.
Confusing root and child modules
Remember: the directory you run Terraform in is the root module. Everything called from there is a child module.
Final Thoughts
Using Terraform modules is a major step from beginner infrastructure code to maintainable real-world infrastructure as code. Modules help you avoid copy-paste, adopt proven patterns, and design cleaner root configurations.
The key ideas to remember are:
- every Terraform directory is a module
- the directory you run Terraform in is the root module
- called modules are child modules
moduleblocks pass inputs in- outputs return useful values back out
terraform initdownloads modules- Registry modules are powerful, but should be used thoughtfully and versioned carefully
Once you are comfortable using modules, the next step is learning how to design and write your own reusable modules.
The biggest mindset shift is simple: stop seeing a module as a shortcut and start seeing it as a reusable contract. Good module usage means understanding the inputs, trusting the outputs, pinning the version, and reviewing what the module really creates before you rely on it in production.
Knowledge Check
Question 1: Root vs Child Module
What is the root module in Terraform?
Question 2: Module Source
Which argument in a module block tells Terraform where to find the module?
Question 3: Versioning
Why is pinning a Terraform Registry module version considered a best practice?