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.
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.
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:
Bad signals: wrapping a single resource in a module that adds nothing but indirection, or modularising because it feels tidier.
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.
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.
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.
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.
| Sub-topic | What you will learn |
|---|---|
| Using Modules | Module blocks, the registry, versions and sources |
| Writing Modules | Structure, interface design, testing and publishing |
| Task | Syntax |
|---|---|
| Call a registry module | source = "namespace/name/provider" + version |
| Call a local module | source = "./modules/vpc" |
| Call a Git module | source = "git::https://...//subdir?ref=v1.0.0" |
| Read a module output | module.<name>.<output> |
| Create N instances | for_each on the module block |
| Move into a module | moved { from = ... to = ... } |
| Fetch modules | terraform init |
| Generate docs | terraform-docs markdown . |
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.
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.
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.
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.
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.
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.
Learn how to call Terraform modules with a module block from local paths or the Registry, pass inputs, pin versions, run init and use module outputs.
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.