Terraform Modules

By Vishvesh Patel · DevOps EngineerUpdated September 13, 2026

What modules are, when to create one, how to design a good interface, versioning and sourcing, and how to move resources into modules without recreating them.

A module is just a directory of .tf files. That is the whole definition, which means you have been writing modules since your first configuration, whether or not you called them that.

The directory you run Terraform in is the root module. Anything it calls with a module block is a child module.

What modules are for

Without them, deploying the same pattern three times means copying sixty resources three times, and a fix has to be applied in three places. Where it inevitably gets applied in two.

A module packages those resources behind an interface of inputs and outputs:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "production"
  cidr = "10.0.0.0/16"

  azs             = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = false
}

That block creates a VPC, subnets across three zones, route tables, an internet gateway and NAT gateways, perhaps sixty resources, from twelve lines that state intent rather than mechanism.

When to create one

Not on the first use. Premature abstraction produces modules shaped around a single caller, with awkward inputs nobody else can use. Write the resources directly, write them a second time, notice the pattern, then extract.

Good signals that something should be a module:

  • The same group of resources is deployed more than twice.
  • A set of resources has a coherent purpose worth naming, "an application service" rather than four separate resources.
  • Different environments need the same shape with different values.

Bad signals: wrapping a single resource in a module that adds nothing but indirection, or modularising because it feels tidier.

Using someone else's

For well-trodden infrastructure, use the registry. The AWS VPC and EKS modules handle a large number of edge cases that take a long time to rediscover, and reimplementing either is rarely a good use of time.

module "eks" {
  source  = "terraform-aws-modules/eks/aws"
  version = "~> 20.0"
  # ...
}

Always pin version. A pessimistic constraint like ~> 5.0 allows patch and minor updates while preventing a major version arriving unannounced. For Git sources, pin a tag:

source = "git::https://github.com/acme/tf-modules.git//network?ref=v1.4.0"

An unpinned module means an upstream change can alter your infrastructure the next time anyone runs init -upgrade.

Note the // in that Git source. It separates the repository from the subdirectory within it, which is how a monorepo of modules is referenced.

Writing your own

modules/s3-bucket/
├── main.tf        # resources
├── variables.tf   # inputs
├── outputs.tf     # outputs
├── versions.tf    # terraform and provider constraints
└── README.md      # usage example

Two design principles matter more than the rest.

Keep the required surface small. Everything that can have a sensible default should, so the simplest call is a few lines. A module with twenty required inputs is one people avoid.

Expose outputs generously. A caller cannot reach inside a module, so anything they might plausibly need, IDs, ARNs, endpoints, should be an output. Adding one later is easy; working around a missing one is not.

variable "bucket_name" {
  type = string
}

variable "versioning_enabled" {
  type    = bool
  default = true      # sensible default, not a required decision
}

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}

Do not declare a provider block inside a module. Declare required_providers in versions.tf to state what the module needs, and let the provider configuration be inherited from the root. A module with its own provider block cannot be used across regions or accounts, and cannot be removed cleanly from state.

Moving resources into a module

This is where people get caught. Moving aws_instance.web into a module changes its state address to module.compute.aws_instance.web, so Terraform sees the old address gone and a new one appear, and plans a destroy and create.

Use a moved block, which declares the rename declaratively and is reviewable in a pull request:

moved {
  from = aws_instance.web
  to   = module.compute.aws_instance.web
}

Run a plan afterwards and confirm it is empty before applying. terraform state mv does the same thing imperatively and is fine for a one-off, but leaves no record in the codebase.

How deep to nest

Shallowly. One or two levels is comfortable. Three or more makes debugging genuinely unpleasant, because tracing a value means walking several interfaces and errors arrive with long qualified addresses.

A workable structure is a root module per environment calling a handful of composed modules, each of which may call one layer of small building blocks. If you are counting levels, flatten it.

Explore further in this section

Sub-topicWhat you will learn
Using ModulesModule blocks, the registry, versions and sources
Writing ModulesStructure, interface design, testing and publishing

Quick reference

TaskSyntax
Call a registry modulesource = "namespace/name/provider" + version
Call a local modulesource = "./modules/vpc"
Call a Git modulesource = "git::https://...//subdir?ref=v1.0.0"
Read a module outputmodule.<name>.<output>
Create N instancesfor_each on the module block
Move into a modulemoved { from = ... to = ... }
Fetch modulesterraform init
Generate docsterraform-docs markdown .

Final Thoughts

Modules are how Terraform configurations stay maintainable past a few hundred lines, and how a pattern gets defined once instead of copied. The judgement is knowing when. Extract after the second repetition, not the first, and design the interface for the caller rather than for the implementation. Pin every version, keep nesting shallow, and use moved blocks when refactoring so a reorganisation never becomes a rebuild.


Frequently Asked Questions

What is a Terraform module?

Any directory containing .tf files. The directory you run Terraform in is the root module; any directory it calls with a module block is a child module. Modules exist to package a group of related resources behind a defined interface of inputs and outputs, so a VPC with its subnets, route tables and gateways becomes one block with a few arguments rather than sixty resources repeated across projects.

When should I create a module instead of writing resources directly?

After the second repetition, not the first. Extracting too early produces modules shaped around one caller's needs, with awkward interfaces that do not generalise. Good signals are the same group of resources deployed more than twice, a set of resources with a coherent purpose worth naming, or several environments needing the same shape with different values. Wrapping a single resource in a module usually adds indirection without benefit.

Should I use community modules from the Terraform Registry?

For well-trodden infrastructure, yes. The AWS VPC and EKS modules in particular handle a large number of edge cases that are slow to rediscover. Write your own for patterns specific to your organisation, or when a community module's flexibility brings more complexity than your case justifies. A common middle path is wrapping a registry module in a thin internal one that fixes your naming and tagging conventions.

How do I pin a module version?

Set version with a pessimistic constraint such as ~> 5.0 when sourcing from a registry, which allows patch and minor updates but blocks an unannounced major version. For Git sources, use ?ref=v1.2.0 to pin a tag rather than tracking a branch. Without pinning, an upstream change can alter your infrastructure the next time anyone runs terraform init -upgrade, which is exactly the kind of surprise infrastructure code exists to prevent.

Why does Terraform want to recreate everything after I moved resources into a module?

Because the state address changed: aws_instance.web became module.compute.aws_instance.web, and Terraform treats the old address as removed and the new one as added. Add a moved block declaring the change, which is applied by a normal plan and is reviewable in a pull request, or use terraform state mv for a one-off. Either way, run a plan afterwards and confirm it is empty before applying.

Should a module contain its own provider block?

No. Declare required_providers in the module's versions.tf to state which providers and versions it needs, but leave the provider configuration, region, credentials, aliases, to the root module, which the child inherits. A module with its own provider block cannot be used with multiple regions or accounts and cannot be cleanly removed from state. When a module genuinely needs a non-default provider, pass an alias explicitly through the providers argument.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Terraform Playground

Run init, plan, apply and destroy against simulated infrastructure.

When it breaks

Related Resources