Quick answer: You reuse code in Terraform by packaging a set of resources into a module and calling it with a module block. The source argument points to where the module lives (a local folder, a Git repository or the Terraform Registry), input variables customise each call, and outputs hand results back. Call the same module three times with different inputs and you get three consistent copies of that infrastructure without copy-pasting a single resource.
Copy-paste is the enemy of reliable infrastructure. Two near-identical VPC definitions drift apart, one gets a security fix and the other does not, and nobody notices until an audit. This guide shows how to call modules from every supported source, how to pass data in and out, how to create multiple instances with for_each, how to turn your own code into a module, and the reuse mistakes that cost teams real downtime.
Why modules are Terraform’s unit of reuse
Terraform has no functions you can define yourself, no classes, and no includes. The only abstraction mechanism is the module: a directory of .tf files that accepts variables and returns outputs. Everything that is reusable in Terraform, from the official AWS VPC module with millions of downloads to the internal “standard microservice” module at a bank, is a module.
Reuse through modules gives you three things that copy-paste never will:
- A single place to fix bugs. Patch the module, bump the version, and every consumer picks up the fix on their next apply.
- Enforced standards. If the module always enables encryption and tagging, nobody can forget them.
- A smaller surface to review. Reviewers read ten lines of module call instead of two hundred lines of resources.
Calling a module: the module block
A module call has one mandatory argument, source, plus whatever input variables the module declares. Here is the classic example of consuming a VPC module from a Git repository:
module "vpc" {
source = "git::https://github.com/tkh-labs/terraform-aws-vpc.git?ref=v2.3.0"
name = "payments-prod"
vpc_cidr = "10.0.0.0/16"
public_subnets_cidr = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets_cidr = ["10.0.10.0/24", "10.0.20.0/24"]
tags = {
Team = "payments"
Environment = "prod"
}
}
# Use the module's outputs elsewhere
resource "aws_db_subnet_group" "main" {
name = "payments-prod"
subnet_ids = module.vpc.private_subnet_ids
}
When you run terraform init, Terraform clones the repository at tag v2.3.0 into .terraform/modules/. During plan and apply every resource inside the module is addressed as module.vpc.aws_subnet.private["..."], so it is still fully visible in plans and state. Outputs are read with module.<name>.<output>, and those references are what Terraform uses to order resources correctly, as explained in Understanding Resource Dependencies and Ordering in Terraform.
Where modules can come from
| Source type | Example source value |
Version pinning | Typical use |
|---|---|---|---|
| Local path | ./modules/vpc |
None (same repo) | Modules private to one project |
| Terraform Registry | terraform-aws-modules/vpc/aws |
version = "~> 5.0" |
Public, widely used modules |
| Git (HTTPS or SSH) | git::https://github.com/org/repo.git?ref=v1.2.0 |
?ref= tag or commit |
Private modules in your own repos |
| Private registry | app.terraform.io/acme/vpc/aws |
version argument |
Enterprises using HCP Terraform or Spacelift |
| HTTP / S3 / GCS archive | s3::https://s3-ap-south-1.amazonaws.com/bucket/vpc.zip |
File name | Air-gapped or regulated environments |
Only Registry sources support the separate version argument. For Git, the ?ref= query parameter is your version pin. Local paths have no pinning, which is fine when module and caller live in one repository and change together.
Creating several instances of the same module
Reuse really pays off when you need the same thing more than once. Instead of three module blocks for three environments, use for_each over a map:
locals {
environments = {
dev = { cidr = "10.10.0.0/16", nat_gateways = 1 }
staging = { cidr = "10.20.0.0/16", nat_gateways = 1 }
prod = { cidr = "10.30.0.0/16", nat_gateways = 3 }
}
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
for_each = local.environments
name = "tkh-${each.key}"
cidr = each.value.cidr
azs = ["ap-south-1a", "ap-south-1b", "ap-south-1c"]
private_subnets = [for i in range(3) : cidrsubnet(each.value.cidr, 8, i)]
public_subnets = [for i in range(3) : cidrsubnet(each.value.cidr, 8, i + 100)]
enable_nat_gateway = true
single_nat_gateway = each.value.nat_gateways == 1
tags = { Environment = each.key }
}
output "prod_vpc_id" {
value = module.vpc["prod"].vpc_id
}
Each instance is addressed as module.vpc["dev"], module.vpc["prod"] and so on. Adding a fourth environment is one more line in the map. Module-level for_each and count have been supported since Terraform 0.13, so every 1.x release handles this natively.
Turning your own code into a reusable module
Suppose you already have a working root configuration that creates an S3 bucket with versioning, encryption and public-access blocking. Making it reusable is a four-step refactor:
- Move the resource blocks into
modules/secure-bucket/main.tf. - Replace hard-coded values with variables in
variables.tf. - Expose what callers need in
outputs.tf. - Replace the original resources in the root with a
moduleblock and runterraform plan. Usemovedblocks so existing resources are renamed in state instead of destroyed and recreated.
# Root module, after the refactor
module "logs_bucket" {
source = "./modules/secure-bucket"
bucket_name = "tkh-app-logs-prod"
retention_days = 90
}
# Tell Terraform the old resource now lives inside the module
moved {
from = aws_s3_bucket.logs
to = module.logs_bucket.aws_s3_bucket.this
}
Without the moved block, Terraform would see one resource disappearing and a new one appearing, and plan to delete a production bucket. With it, the plan shows zero changes. For the file layout and design conventions a module should follow, see Terraform Module Structure and Best Practices.
Publishing a module for others
Once a module works, make it available to your organisation. For the public Terraform Registry, the repository must be public on GitHub, named terraform-<provider>-<name>, and tagged with semantic versions such as v1.0.0. For private use, a tagged Git repository is enough; teams on HCP Terraform or Terraform Enterprise can publish to a private registry that supports the version argument and shows generated documentation. Either way, write a README with a copy-pasteable example, and treat every change to a variable or output as a potentially breaking change.
Six module reuse mistakes to avoid
- Pointing
sourceat a branch.?ref=mainmeans the module changes underneath you without a code review. Always pin to a tag or commit. - Forking a public module to change one default. Now you own all future maintenance. Pass the value as an input instead, or wrap the module in a thin local module.
- Refactoring into modules without
movedblocks. Terraform will plan to destroy and recreate everything. Check the plan before applying. - Forgetting
terraform initafter changingsourceor version. Terraform keeps using the cached copy until you runinitagain (add-upgradefor Registry modules). - Using
countfor named instances. Deleting the first environment in a list shifts every index. Usefor_eachwith a map. - Passing secrets through module inputs in plain text. Mark the variable
sensitive = trueand read the value from a secrets manager data source or an environment variable.
Frequently asked questions
Can a module call another module?
Yes. A “wrapper” module that calls the public VPC module with your organisation’s defaults is a common pattern. Keep nesting to two or three levels so plans stay readable.
Where does Terraform store downloaded modules?
In .terraform/modules/ inside your working directory, with a modules.json manifest. Add .terraform/ to .gitignore; it is rebuilt by terraform init.
How do I pass a provider alias into a module?
Use the providers meta-argument: providers = { aws = aws.mumbai }. The module itself should declare only required_providers, never a provider block.
Is it safe to use community modules from the Registry?
Modules under the terraform-aws-modules namespace and other verified publishers are widely used and well maintained. Still read the code, pin a version, and run terraform plan to see exactly what will be created before applying.
Key takeaways
- Modules are the only reuse mechanism in Terraform; everything shareable is a module.
- Call a module with a
moduleblock, pass inputs as arguments, and read results asmodule.name.output. - Pin every remote source to a version, and use
for_eachto stamp out multiple instances from one definition. - When refactoring existing resources into a module, add
movedblocks so nothing is destroyed.
Want to build and publish your own Terraform modules as part of a real CI/CD pipeline? Our DevOps course takes you from first resource to production-ready modules with mentor support and placement assistance. For step-by-step video walkthroughs, subscribe to our YouTube channel.


