Terraform

How to Reuse Code with Terraform Modules: A Practical Guide

AGAnurag Gupta04 Apr 2025 · Updated 04 Oct 2026 · 7 min read
How to Reuse Code with Terraform Modules: A Practical Guide

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:

  1. Move the resource blocks into modules/secure-bucket/main.tf.
  2. Replace hard-coded values with variables in variables.tf.
  3. Expose what callers need in outputs.tf.
  4. Replace the original resources in the root with a module block and run terraform plan. Use moved blocks 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

  1. Pointing source at a branch. ?ref=main means the module changes underneath you without a code review. Always pin to a tag or commit.
  2. 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.
  3. Refactoring into modules without moved blocks. Terraform will plan to destroy and recreate everything. Check the plan before applying.
  4. Forgetting terraform init after changing source or version. Terraform keeps using the cached copy until you run init again (add -upgrade for Registry modules).
  5. Using count for named instances. Deleting the first environment in a list shifts every index. Use for_each with a map.
  6. Passing secrets through module inputs in plain text. Mark the variable sensitive = true and 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 module block, pass inputs as arguments, and read results as module.name.output.
  • Pin every remote source to a version, and use for_each to stamp out multiple instances from one definition.
  • When refactoring existing resources into a module, add moved blocks 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.

AG
Written byAnurag Gupta

Part of the Techknowledgehub team of industry mentors, writing practical guides to help you build a job-ready tech career.

More articles by Anurag Gupta →
Keep reading

Related articles

Leave a Reply