What a module is
Any directory that contains .tf files is a module. That has been true
since day one of Terraform. When you write main.tf in an empty folder
and run terraform init, you are already working with a module. It
happens to be the only one, it is called the root module, and nothing
else exists alongside it.
Modules become useful when you want to reuse a piece of HCL. Instead of copy-pasting ten buckets with identical tags and lifecycle rules, you write one module and call it ten times with different parameters.
Root vs child
| Term | What it is |
|---|---|
| Root module | The directory from which you run terraform init/plan/apply. It owns the state, the lockfile, and .terraform/. |
| Child module | A directory that the root (or another child) references via a module block. It has no state of its own. Its resources live in the parent's state under the prefix module.<name>.. |
These are not two different artifact types. They are roles. Any module becomes the root when you run terraform from it, and a child when something else references it.
Minimal child module
File layout:
modules/s3-bucket/
├── main.tf # the resources themselves
├── variables.tf # what the module accepts
└── outputs.tf # what it exposes
All three files are a convention, not a requirement. Terraform reads every
.tf file in the directory and merges them into one configuration. You
could put everything in a single main.tf and it would work. Don't do
that.
Example (modules/s3-bucket/main.tf):
resource "aws_s3_bucket" "this" {bucket = var.name
tags = merge(
{ Module = "s3-bucket" },var.tags,
)
}
variables.tf:
variable "name" {type = string
description = "Bucket name. Must be globally unique."
}
variable "tags" {type = map(string)
default = {}}
outputs.tf:
output "arn" {value = aws_s3_bucket.this.arn
}
output "bucket" {value = aws_s3_bucket.this.bucket
}
That is a complete, callable module.
Calling a module from root
In the root module:
module "logs" {source = "./modules/s3-bucket"
name = "linuxlab-logs-${random_id.s.hex}" tags = { Owner = "student" }}
resource "random_id" "s" {byte_length = 4
}
output "logs_arn" {value = module.logs.arn
}
A few things to notice:
sourceis required. It can be./local,git::..., a registry address, an archive, or S3. See tf-module-sources.- The name
"logs"is yours to choose within this root module. A different root can call the same module"backup". That is its business. - To reference a module output:
module.<name>.<output_name>, notmodule.<name>.<resource>.<attr>. Only outputs are visible outside a module.
What ends up in state
The root module called module "logs". After apply, the state contains:
$ terraform state list
module.logs.aws_s3_bucket.this
random_id.s
The resource address inside a module follows the pattern
module.<name_in_parent>.<type>.<name>. If that module calls its own child,
you get module.A.module.B.<type>.<name>. Nesting shows up directly in the
address.
When to extract a module
Not at the second repetition. At the third or fourth.
- Two identical blocks: copy-paste is fine. A shared local or variable will remove the duplication without needing a module.
- Three identical blocks across different projects: worth thinking about. If they will stay identical, a module makes sense. If they are likely to diverge, hold off.
- Four or more: unmanageable without a module. Better late than never.
Premature abstraction (one use case so far) is a common mistake. You build a "universal" abstraction for needs that do not yet exist, then the second use case turns out to need entirely different parameters and you have to rewrite the module from scratch. Copy-paste and wait.
init and modules
terraform init downloads module sources into .terraform/modules/:
.terraform/
└── modules/
├── modules.json
└── logs/ # local symlink or reference to ./modules/s3-bucket
For source = "./modules/X": a symlink. For git or a registry: a real clone.
If you edit the module HCL locally, changes are visible immediately (they are
just files in the repository). If you change source = "git::..."?ref=v1.2.3,
you need terraform init -upgrade to download the new version. See
tf-init-modules.
Pitfalls
-
Modules have no state of their own. Their resources live in the parent's state. To "extract" a module into a separate state, you need
state mvor a new state withterraform_remote_state. See tf-state-manipulation. -
Providers are not configured inside a module by default. A module inherits providers from the root. If you need a different region or a different alias, pass it via
providers = { aws = aws.eu }. See tf-module-composition. -
sourcecannot be interpolated.source = "git::...?ref=${var.v}"does not work. Terraform readssourceduringinit, before variables are resolved. The version must be a literal, not an expression. -
Module outputs are evaluated on every plan. If a module contains a heavy local or a large
forexpression, it runs on every plan. Do not put expensive computations in outputs. Move them into locals inside the module. -
Changing
sourcewithout runninginit -upgradefirst is dangerous. You switch a path from local to git. Terraform sees a "new" module, sees the old one is gone, and tries to destroy all old resources and recreate them. Runterraform init -upgradefirst, thenplan, and read carefully what it intends to do.