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.
As infrastructure grows, copy-paste becomes dangerous:
Modules reduce those problems by standardizing common infrastructure patterns.
Understanding this distinction is essential.
The root module is the Terraform directory you run commands in.
Example:
project-root/
main.tf
variables.tf
outputs.tfIf you run Terraform from project-root/, that directory is the root module.
A child module is any module called by another module.
Example structure:
project-root/
main.tf
modules/
vpc/
main.tf
variables.tf
outputs.tfIf project-root/main.tf contains:
module "network" {
source = "./modules/vpc"
}then modules/vpc is a child module.
module BlockTerraform 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
}module "network" gives the module call a local namesource tells Terraform where to find the moduleThis is similar to calling a function in programming:
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"
}
}| 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 at registry.terraform.io is the central catalog of published Terraform providers and modules.
It includes:
Using Registry modules can save huge amounts of time because common infrastructure patterns already exist.
Instead of writing a VPC module from scratch, you may be able to start from a mature, widely used module that already handles:
Registry modules are powerful, but do not treat them as magic. Always read:
Using a module without understanding what it creates is risky.
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:
terraform-aws-modulesvpcawsTwo widely used AWS examples are the AWS VPC module and the AWS EKS module.
The VPC module from terraform-aws-modules is popular because it handles a lot of standard networking complexity.
Common use cases include:
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
}The EKS module is popular for provisioning Kubernetes clusters on AWS.
It can help manage:
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.
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.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.8.1"
# inputs omitted
}This pins the module to one exact version.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.8"
}This allows patch-level upgrades within the compatible 5.8.x series.
Pinning versions gives you:
For beginners and production teams alike, explicit versions are a good habit.
terraform init Downloads ModulesWhen Terraform sees a module block, it does not automatically know where the module code lives locally. terraform init handles that setup.
terraform initDuring initialization, Terraform:
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.
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:
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.
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.
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:
There is no single right answer.
Often teams use a mix of both.
terraform init after adding or changing modules.This can lead to surprising resource creation, unnecessary cost, or incorrect assumptions.
An unpinned module may change later in ways you did not expect.
Start from the minimal required inputs. Add optional settings only when you understand them.
Remember: the directory you run Terraform in is the root module. Everything called from there is a child module.
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:
module blocks pass inputs interraform init downloads modulesOnce 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.
<MCQExercise exercise={{ id: 'tf-using-modules-q1', title: 'Question 1: Root vs Child Module', description: 'What is the root module in Terraform?', options: [ { id: 'a', text: 'The last module downloaded from the Terraform Registry' }, { id: 'b', text: 'The directory where you run terraform init, plan, and apply' }, { id: 'c', text: 'Any module that contains outputs.tf' }, { id: 'd', text: 'A module that cannot call other modules' }, ], correctId: 'b', explanation: 'The root module is the Terraform working directory where you run commands. It is the top-level module for that deployment.', wrongExplanations: { a: 'Registry modules are usually child modules unless they are the directory you directly run Terraform in.', c: 'Having an outputs.tf file does not define a root module.', d: 'Root modules can absolutely call other modules.', }, }} />
<MCQExercise exercise={{ id: 'tf-using-modules-q2', title: 'Question 2: Module Source', description: 'Which argument in a module block tells Terraform where to find the module?', options: [ { id: 'a', text: 'output' }, { id: 'b', text: 'provider' }, { id: 'c', text: 'source' }, { id: 'd', text: 'backend' }, ], correctId: 'c', explanation: 'The source argument tells Terraform where the module lives, whether that is a local path, Git source, or Terraform Registry address.', wrongExplanations: { a: 'Outputs are returned from a module, not used to locate it.', b: 'Providers define cloud APIs, not module locations.', d: 'Backends store state, not module code.', }, }} />
<MCQExercise exercise={{ id: 'tf-using-modules-q3', title: 'Question 3: Versioning', description: 'Why is pinning a Terraform Registry module version considered a best practice?', options: [ { id: 'a', text: 'It makes Terraform skip terraform init permanently' }, { id: 'b', text: 'It guarantees reproducible behavior and safer upgrades' }, { id: 'c', text: 'It turns the module into a root module automatically' }, { id: 'd', text: 'It prevents the module from using variables' }, ], correctId: 'b', explanation: 'Pinning or constraining module versions helps keep runs reproducible and allows upgrades to happen deliberately instead of unexpectedly.', wrongExplanations: { a: 'terraform init is still required to download modules and providers.', c: 'Version pinning does not change the module type or hierarchy.', d: 'Modules still use variables normally when versions are pinned.', }, }} />
Use registry modules for well-trodden infrastructure where the edge cases are many and someone else has already handled them. The AWS VPC and EKS modules are the classic examples, and reimplementing either is rarely a good use of time. Write your own for patterns specific to your organisation, or when a registry module's flexibility brings more complexity than your use case justifies. A frequent middle path is wrapping a registry module in a thin internal one that fixes your conventions.
Always set version when sourcing from a registry, ideally with a pessimistic constraint such as ~> 5.0 so patch updates flow but a major version cannot arrive unannounced. For Git sources, pin to a tag or commit SHA with ?ref=v1.2.0 rather than tracking a branch. An unpinned module means an upstream change can alter your infrastructure the next time anyone runs init -upgrade, which is exactly the surprise you want to avoid.
Inputs are arguments in the module block, matching the child's variable declarations. Outputs come back as module.<name>.<output> and are the only way to read anything from inside a module. You cannot reference a child's resources directly. This is deliberate: the interface is the contract, and it is what lets a module's internals change without breaking callers.
Shallowly. One or two levels is comfortable; three or more makes debugging painful, because tracing a value means walking through several interfaces and error messages arrive with long qualified addresses. A common and workable structure is a root module per environment calling a handful of composed modules, each of which may call one layer of small building-block modules. If you find yourself counting levels, flatten it.
Because moving a resource into a module changes its state address from aws_instance.web to module.compute.aws_instance.web, and Terraform sees the old address gone and a new one appear. Fix it with moved blocks in configuration, which declare the rename and are reviewable in a pull request, or with terraform state mv for a one-off. Always run a plan afterwards and confirm it is empty before applying.
Learn how to write a reusable Terraform module: structure main.tf, variables.tf and outputs.tf, build an S3 bucket module and call it locally.
Learn how Terraform data sources read existing infrastructure, how the data block works, how to reference its attributes and when to use data over resource.
Choose between Terraform count and for_each: see why count's positional identity can destroy resources, when each is correct and how to migrate safely.