🧱 Terraform Modules Guide

Writing, calling, pinning, refactoring and testing modules, with the errors and traps found by running every case, no cloud account needed

Video: Terraform Modules Guide (DevOps video)
Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Terraform modules: inputs, outputs, sources, providers, refactoring and tests
module "site" { source = "./modules/site" }          terraform test

The Terraform cheat sheet on this site gives modules eight lines, and the Terraform Basics page says that modules, "reusable bundles of configuration", come later. This is later. A module is how Terraform code gets reused: you write a directory of configuration once, give it inputs and outputs, and call it from other configuration as often as you like, with different values. This page builds one small module, calls it every way Terraform allows (local path, public registry, git tag, git branch, many copies at once), and then does the things that go wrong in real projects: bad inputs, secrets passing through, a rename that wants to destroy everything, providers that must be passed down, and a module that has no tests.
Every output below ran for real, on 9 October 2026: Terraform 1.16.5 (checksum verified against HashiCorp's published list) with the local provider 2.9.1, the random provider and the tls provider 4.4.1, in throwaway directories with a private plugin cache. No cloud account and no cloud provider was involved: the module writes small files, which is enough to show everything about how modules behave. Only the file-writing details are toy; the module mechanics are the same for a module that builds a network. Where something was not run, this page says "not run in the lab".

What a module is

A module is a directory of .tf files. That is all. Every Terraform configuration is already one: the directory you run terraform in is the ROOT module. A CHILD module is a directory that the root (or another module) calls with a module block. The call gives it a name, a source and values for its input variables; the child hands results back through output values. Nothing else crosses the boundary: the child cannot see the caller's variables, locals or resources, and the caller cannot see the child's resources, only its outputs. That strict boundary is the whole point: a module is a function with named arguments and named results.
The layout this page uses, and the one that the community converged on:
  main.tf        the resources
  variables.tf   the inputs
  outputs.tf     the outputs
  versions.tf    the Terraform version and the providers the module needs
  templates/     files the module reads (a template, here)
  tests/         the module's tests (see "Testing a module")
Terraform does not care about the file names, only that they end in .tf; the convention is what lets a stranger read your module.

A first module

The module is called site. It turns a name, a port and a list of pages into an nginx-style config file and one HTML file per page. The files, as they are on disk:
$ cat modules/site/versions.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = ">= 2.5"
    }
  }
}
$ cat modules/site/variables.tf
variable "name" {
  description = "Short name of the site, used in file names"
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,20}$", var.name))
    error_message = "name must start with a letter and have 2 to 21 lowercase letters, digits or hyphens."
  }
}

variable "port" {
  description = "TCP port the site listens on"
  type        = number
  default     = 80

  validation {
    condition     = var.port >= 1 && var.port <= 65535
    error_message = "port must be between 1 and 65535."
  }
}

variable "pages" {
  description = "Page name => heading text"
  type        = map(string)
  default     = { index = "Hello" }
}

variable "owner" {
  description = "Who to call"
  type = object({
    team  = string
    email = optional(string, "ops@example.org")
  })
}

variable "api_key" {
  description = "A secret that must not show up in plans"
  type        = string
  default     = ""
  sensitive   = true
}
$ cat modules/site/templates/site.conf.tftpl
# Site ${name}, owner: ${owner}
server {
    listen ${port};
    server_name ${name}.example.org;
    root ${dir};
}
$ cat modules/site/main.tf
locals {
  site_dir = "${path.root}/out/${var.name}"
}

resource "local_file" "conf" {
  filename = "${local.site_dir}/${var.name}.conf"
  content = templatefile("${path.module}/templates/site.conf.tftpl", {
    name  = var.name
    port  = var.port
    dir   = local.site_dir
    owner = "${var.owner.team} <${var.owner.email}>"
  })
}

resource "local_file" "page" {
  for_each = var.pages
  filename = "${local.site_dir}/${each.key}.html"
  content  = "<h1>${each.value}</h1>\n"
}

resource "local_sensitive_file" "secret" {
  count    = var.api_key == "" ? 0 : 1
  filename = "${local.site_dir}/api.key"
  content  = var.api_key
}
$ cat modules/site/outputs.tf
output "conf_path" {
  description = "Where the nginx config was written"
  value       = local_file.conf.filename
}

output "page_paths" {
  description = "Paths of the generated pages"
  value       = [for p in local_file.page : p.filename]
}

output "url" {
  value = "http://${var.name}.example.org:${var.port}/"
}

output "key_fingerprint" {
  description = "A derived value that carries the secret's sensitivity"
  value       = sha256(var.api_key)
  sensitive   = true
}
Read it as a function. The inputs: name (required, checked by a rule), port (optional, default 80, checked), pages (a map of page name to heading), owner (an object whose email part is optional) and api_key (sensitive, optional). The results: the path of the config, the paths of the pages, the URL, and a fingerprint of the key. The module needs the local provider, version 2.5 or newer, and Terraform 1.9 or newer; saying so in versions.tf is what makes a module safe to share.
The root module calls it:
$ cat main.tf
module "site" {
  source = "./modules/site"

  name  = "shop"
  port  = 8080
  pages = { index = "Welcome to the shop", about = "About us" }
  owner = { team = "web" }
}

output "shop_url" {
  value = module.site.url
}

output "shop_files" {
  value = concat([module.site.conf_path], module.site.page_paths)
}
Initialize, validate and plan. terraform init is the step that "installs" modules (for a local path it just records the directory); the plan lists every resource by its full address, module first:
$ terraform init
Initializing the backend...

Initializing modules...
- site in modules/site

Initializing provider plugins...
- Finding hashicorp/local versions matching ">= 2.5.0"...
- Installing hashicorp/local v2.9.1...
- Installed hashicorp/local v2.9.1 (signed by HashiCorp)
...
Terraform has been successfully initialized!
$ terraform validate
Success! The configuration is valid.
$ terraform plan -out=tfplan
  # module.site.local_file.page["index"] will be created
  + resource "local_file" "page" {
      + content              = <<-EOT
            <h1>Welcome to the shop</h1>
        EOT
      ...
      + filename             = "./out/shop/index.html"
    }

Plan: 3 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + shop_files = [
      + "./out/shop/shop.conf",
      + "./out/shop/about.html",
      + "./out/shop/index.html",
    ]
  + shop_url   = "http://shop.example.org:8080/"
(the other two resources are left out.) Note the address module.site.local_file.page["index"]: module name, resource type, resource name, then the for_each key. Apply:
$ terraform apply tfplan
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

shop_files = [
  "./out/shop/shop.conf",
  "./out/shop/about.html",
  "./out/shop/index.html",
]
shop_url = "http://shop.example.org:8080/"
$ terraform state list
module.site.local_file.conf
module.site.local_file.page["about"]
module.site.local_file.page["index"]
$ cat out/shop/shop.conf
# Site shop, owner: web <ops@example.org>
server {
    listen 8080;
    server_name shop.example.org;
    root ./out/shop;
}
Two details: the owner's email is the module's default, because the caller gave only team; and the module's own outputs (conf_path, page_paths, url) are NOT shown by terraform output, because only the ROOT module's outputs are. The root had to re-export what it wanted, which is what the two output blocks in main.tf do.

What Terraform records about modules

init leaves a small index of the modules it found:
$ python3 -m json.tool .terraform/modules/modules.json
{
    "Modules": [
        {
            "Key": "",
            "Source": "",
            "Dir": "."
        },
        {
            "Key": "site",
            "Source": "./modules/site",
            "Dir": "modules/site"
        }
    ]
}
The Key is the name of the module call, the Source is what the configuration said, the Dir is where the code is. This is why renaming a call, or changing a source, needs terraform init again (see the sections below). To see which providers each module needs:
$ terraform providers
Providers required by configuration:
.
└── module.site
    └── provider[registry.terraform.io/hashicorp/local] >= 2.5.0

Providers required by state:

    provider[registry.terraform.io/hashicorp/local]
And what the lock file holds: only providers.
$ head -8 .terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/hashicorp/local" {
  version     = "2.9.1"
  constraints = ">= 2.5.0"
  hashes = [
    "h1:qGLHCuYSus+uHNnoEL4SuJqOs5yrNOcB7gnuHoVqizo=",
The lock file pins the PROVIDER to 2.9.1. Nothing in it pins a module. How a module is pinned is the subject of a later section, and the difference matters. (terraform get re-fetches modules and printed nothing here, because there was nothing to do.)

path.module, path.root and where files go

Inside a module, three values tell you where you are:
$ echo 'path.module' | terraform console
"."
$ echo 'path.root' | terraform console
"."
$ echo 'path.cwd' | terraform console
"/home/liloland/tfmod-lab/lab1"
  path.module   the directory of the module whose code is being evaluated (here the root module, so ".")
  path.root     the directory of the ROOT module
  path.cwd      the directory terraform was started in
For a module that lives in ./modules/site in the same repository, path.module is "modules/site" inside it. The example module uses path.module to find its template (that is code that ships with the module) and path.root to decide where its OUTPUT goes (a file belongs to the caller, not to the module). That choice matters, and I proved why. A second, tiny module that lives in a git repository wrote one file with path.module and one with path.root:
$ terraform apply -auto-approve
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

module_dir = ".terraform/modules/greet/modules/greet"
$ find . -name 'greeting-*' | sort
./out/greeting-world.txt
./.terraform/modules/greet/modules/greet/greeting-world.txt
A module downloaded from git or the registry lives under .terraform/modules/, so path.module there is a CACHE directory. The file written with path.module went into the cache. It is thrown away when terraform init replaces that directory (a new ref, -upgrade, or deleting .terraform), and then the state says the file exists while the disk disagrees: after I switched the module to another tag, the plan wanted to create that file again:
  # module.greet.local_file.inside_module will be created
  # module.greet.local_file.inside_root must be replaced
Rule: read module-owned files with path.module, write outputs under a path the caller controls (pass the directory in as a variable, or use path.root).

Inputs: types, validation, and the typo that passes

Inputs are variable blocks in the child. A well-made input says its type, its default (if it has a sensible one), and rules for what is acceptable. When the caller gets it wrong, Terraform reports the error at the CALLER's line, with a pointer to the declaration. Six deliberate mistakes, each shown with the module arguments that caused it:
a) a required input is missing
      name = "shop"
Error: Missing required argument
  on main.tf line 1, in module "site":
   1: module "site" {
The argument "owner" is required, but no definition was found.
b) the wrong type
      port  = "eighty"
Error: Invalid value for input variable
  on main.tf line 4, in module "site":
   4:   port  = "eighty"
The given value is not suitable for module.site.var.port declared at
modules/site/variables.tf:11,1-16: a number is required.
c) a validation rule says no
      name  = "Shop!"
Error: Invalid value for variable
  on main.tf line 3, in module "site":
   3:   name  = "Shop!"
    ├────────────────
    │ var.name is "Shop!"
name must start with a letter and have 2 to 21 lowercase letters, digits or
hyphens.
This was checked by the validation rule at modules/site/variables.tf:5,3-13.
d) an argument the module does not have
      colour = "red"
Error: Unsupported argument
  on main.tf line 5, in module "site":
   5:   colour = "red"
An argument named "colour" is not expected here.
f) a number outside the validated range
      port  = 70000
Error: Invalid value for variable
  on main.tf line 4, in module "site":
   4:   port  = 70000
    ├────────────────
    │ var.port is 70000
port must be between 1 and 65535.
This was checked by the validation rule at modules/site/variables.tf:16,3-13.
(the case between d) and f) is the next paragraph.) The message of a validation rule is yours to write: say what is acceptable, not just that the value was wrong. Now case e): an object with an attribute the type does not mention. The owner type lists team and an optional email. I passed an extra key, phone:
      owner = { team = "web", phone = "555" }
There was NO error: Terraform accepted the extra key without complaint (the plan in that case wanted other changes only because my test call left out port and pages). That has a dangerous consequence: a TYPO in an optional attribute is silently ignored too. I spelled email wrong (emial) in an applied configuration:
      owner  = { team = "web", emial = "web-team@example.org" }
$ terraform plan
No changes. Your infrastructure matches the configuration.
$ terraform validate
Success! The configuration is valid.
The module quietly used its default address (ops@example.org) and everything said it was fine. With the right spelling the plan shows the real change:
  # module.site.local_file.conf must be replaced
          - # Site shop, owner: web <ops@example.org>
          + # Site shop, owner: web <web-team@example.org>
Plan: 1 to add, 0 to change, 1 to destroy.
Two defences. Review plans for changes you EXPECTED and did not get. And for modules that matter, make the inputs flat variables (each its own variable block) instead of one object with optional parts, because an unknown argument to a module call IS an error (case d), while an unknown key inside an object is not.

Outputs, and secrets that pass through a module

Outputs are the module's return values, and the only things a caller can read. A secret that goes in must not leak out by accident, and Terraform helps in a few places and fails to in others. The input api_key is marked sensitive = true. Passing a value in:
module "site" {
  source  = "./modules/site"
  ...
  api_key = "s3cret-token-123"
}
$ terraform plan
  # module.site.local_sensitive_file.secret[0] will be created
  + resource "local_sensitive_file" "secret" {
      + content              = (sensitive value)
      ...
The plan hides the content. The module's output key_fingerprint is a hash of the key, so it is sensitive too (the module marked it). A root module that tries to re-export a sensitive value without saying so is refused:
output "fingerprint" {
  value = module.site.key_fingerprint
}
Error: Output refers to sensitive values
  on main.tf line 10:
  10: output "fingerprint" {
To reduce the risk of accidentally exporting sensitive data that was intended
to be only internal, Terraform requires that any root module output
containing sensitive data be explicitly marked as sensitive, to confirm your
intent.
If you do intend to export this data, annotate the output value as sensitive
by adding the following argument:
    sensitive = true
With sensitive = true in the root output, apply works and shows the placeholder:
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

fingerprint = <sensitive>
That is where the protection ends. Three things it does NOT do:
$ terraform output fingerprint
"f18a7567f21177f723531627688340f45d67546e01b1f209772ee10817d9df76"
$ terraform output -json fingerprint
"f18a7567f21177f723531627688340f45d67546e01b1f209772ee10817d9df76"
Asking for a sensitive output BY NAME prints it. And the state file keeps the secret in plain text:
$ grep -n 's3cret-token-123' terraform.tfstate
129:            "content": "s3cret-token-123",
And a file made by local_sensitive_file got permissions "0700":
$ ls -l out/shop/api.key | awk '{print $1, $9}'
-rwx------ out/shop/api.key
(0700 is that resource's default: it hides the content in plans, and protects the file on disk.) "Sensitive" means "do not print it in normal output". It does not mean encrypted. Treat the state like a secret store: restrict who can read it, use a remote backend with access control and encryption, and do not commit it.
Give every input and output a description: it is what documentation tools show (terraform-docs was not run in the lab). The root of a configuration is also a module, so the same rules apply to its variables.

Where a module comes from: local path, registry, git

The source argument says where the code is, and its FORM decides how Terraform finds it. Forms I ran:
a local path. It MUST start with ./ or ../ :
module "site" {
  source = "modules/site"
...
Error: Invalid module source address
  on main.tf line 2, in module "site":
   2:   source = "modules/site"
Terraform failed to determine your intended installation method for remote
module package "modules/site".
Without the leading ./ the string is not a path: Terraform tries to guess a remote package and cannot. Other mistakes with local paths:
  version = "~> 1.0"  next to source = "./modules/site":
Error: Invalid registry module source address
  on main.tf line 2, in module "site":
   2:   source  = "./modules/site"
Failed to parse module registry address: can't use local directory
"./modules/site" as a module registry address.
(a version argument means "registry module", and a local path is not one: version belongs only to registry sources.) And a path that is not there:
Error: Unreadable module directory
Unable to evaluate directory symlink: lstat modules/nosuch: no such file or directory
And a module call that was added but not yet installed:
$ terraform plan
Error: Module not installed
  on main.tf line 1:
   1: module "site" {
This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.
the public registry. The address is NAMESPACE/NAME/PROVIDER, and version accepts constraints:
module "dir" {
  source  = "hashicorp/dir/template"
  version = "~> 1.0"

  base_dir = "${path.module}/site-files"
  template_vars = {
    name = "registry"
  }
}
$ terraform init
Initializing modules...
Downloading registry.terraform.io/hashicorp/dir/template 1.0.2 for dir...
- dir in .terraform/modules/dir
$ python3 -m json.tool .terraform/modules/modules.json
...
            "Key": "dir",
            "Source": "registry.terraform.io/hashicorp/dir/template",
            "Version": "1.0.2",
            "Dir": ".terraform/modules/dir"
The registry offered versions 1.0.0, 1.0.1 and 1.0.2 (I asked it), and ~> 1.0 chose the newest, 1.0.2, recorded in modules.json. This module takes a directory of files and returns each file's content type, and ran with no cloud at all:
$ terraform apply -auto-approve
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

files = {
  "hello.txt" = "text/plain; charset=utf-8"
  "index.html" = "text/html; charset=utf-8"
}
(its template file hello.txt.tmpl came out as hello.txt.) A curiosity: the registry's own listing for this module names the "template" provider, but Terraform's providers command showed no providers for it and wrote no lock file: the listing is metadata, the code is what counts. Read a registry module's source before you trust it.
a git repository. The prefix git:: , then the URL, then // to name a sub-directory inside the repository, then ?ref= to choose a tag, branch or commit. For a lab I made a local repository with two tags and used a file:// URL; a real one would be git::https://... or git::ssh://... :
module "greet" {
  source = "git::file:///home/liloland/tfmod-lab/gitrepo//modules/greet?ref=v1.0.0"
  who    = "world"
}
$ terraform init
Initializing modules...
Downloading git::file:///home/liloland/tfmod-lab/gitrepo?ref=v1.0.0 for greet...
- greet in .terraform/modules/greet/modules/greet
- Installed hashicorp/local v2.9.1 (signed by HashiCorp)
Terraform has been successfully initialized!
$ python3 -m json.tool .terraform/modules/modules.json | sed -n 8,13p
        {
            "Key": "greet",
            "Source": "git::file:///home/liloland/tfmod-lab/gitrepo//modules/greet?ref=v1.0.0",
            "Dir": ".terraform/modules/greet/modules/greet"
        }
Other source forms exist (an HTTP URL to an archive, S3 and GCS buckets, Mercurial) and were not run in the lab.

Pinning: tags, branches and the lock file

A module is only as repeatable as its pin. Three things I measured, using the git module above.
1. A CHANGED source is caught, and needs an init. I changed ref=v1.0.0 to ref=v1.1.0 and ran plan:
Error: Module source has changed
  on main.tf line 2, in module "greet":
   2:   source = "git::file:///home/liloland/tfmod-lab/gitrepo//modules/greet?ref=v1.1.0"
The source address was changed since this module was installed. Run
"terraform init" to install all modules required by this configuration.
After terraform init (which printed "Downloading git::file:///home/liloland/tfmod-lab/gitrepo?ref=v1.1.0 for greet...") the plan showed the new module's content:
            Hello world, from version 1.1.0
2. A TAG is a stable pin; a BRANCH is not. I pointed the module at ref=main, applied, and then a colleague committed to main in the module's repository:
$ git log --oneline --decorate | head -3          (in the module repository)
3d0454d (HEAD -> main) wip on main, untagged
488896b (tag: v1.1.0) greet 1.1.0: adds a version output
84a17a3 (tag: v1.0.0) greet 1.0.0
$ terraform init
Initializing modules...
Terraform has been successfully initialized!
$ terraform plan
No changes. Your infrastructure matches the configuration.
Plain terraform init did NOT fetch the new commit: the module was "installed" already, from the cache. Only -upgrade does:
$ terraform init -upgrade
Downloading git::file:///home/liloland/tfmod-lab/gitrepo?ref=main for greet...
Terraform has been successfully initialized!
$ terraform plan
  # module.greet.local_file.inside_root must be replaced
          - Hello world, from version 1.1.0
          + Hello world, from the NEW unreleased main
Plan: 2 to add, 0 to change, 1 to destroy.
So with a branch, two machines (or one machine two weeks apart) can get different code from the same configuration, and nothing tells you. A tag gives the same code every time, as long as nobody moves the tag; a full commit hash cannot move (I did not test one in the lab). For a registry module use an exact version or a constraint like ~> 1.0, and know that a constraint lets updates in on terraform init -upgrade.
3. The lock file does not help. As shown earlier, .terraform.lock.hcl lists providers only. (Terraform records the resolved module version in .terraform/modules/modules.json, which you do not commit.) Pinning a module is something you do in the source and version arguments.
Release your own modules by tagging them (v1.0.0, v1.1.0), keep a change log, and let callers choose when to move. How the registry expects a module to be named and published (terraform-PROVIDER-NAME) was not run in the lab.

Providers and modules

A module that creates resources needs providers, and where those providers are configured is a design decision. The modern rule: a reusable module declares WHICH providers it needs (required_providers, as versions.tf does above) but does not CONFIGURE them. The root module configures providers (region, credentials, endpoints) and the child inherits the default configuration of the same provider. In this lab the local and random providers need no configuration, so inheritance worked without a word.
When a module needs more than one configuration of the same provider (two regions, two accounts), it declares the extra ones with configuration_aliases and the caller passes them in. A module that makes two random pets, one with the default configuration and one with an alias called alt:
terraform {
  required_providers {
    random = {
      source                = "hashicorp/random"
      configuration_aliases = [random.alt]
    }
  }
}

resource "random_pet" "primary" {
  length = 2
}

resource "random_pet" "secondary" {
  provider = random.alt
  length   = 2
}
a) the caller passes nothing:
$ terraform init
Error: Missing required provider configuration
  on main.tf line 5:
   5: module "ids" {
The child module requires an additional configuration for provider
hashicorp/random, with the local name "random.alt".
Refer to the module's documentation to understand the intended purpose of
this additional provider configuration, and then add an entry for random.alt
in the "providers" meta-argument in the module block to choose which provider
configuration the module should use for that purpose.
b) the caller passes only the default one (providers = { random = random }): the same error, preceded by a warning:
Warning: Reference to undefined provider
  on main.tf line 7, in module "ids":
   7:   providers = { random = random }
There is no explicit declaration for local provider name "random" in the root
module, so Terraform is assuming you mean to pass a configuration for
provider "hashicorp/random".
To clarify your intent and silence this warning, add to the root module a
required_providers entry named "random" with source = "hashicorp/random", or
a different source address if appropriate.
c) both passed, in the root:
provider "random" {
  alias = "alt"
}

module "ids" {
  source    = "./modules/ids"
  providers = { random = random.alt, random.alt = random.alt }
}
$ terraform apply -auto-approve
Outputs:

names = [
  "capital-ant",
  "adapting-bluejay",
]
$ terraform providers
Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/random]
└── module.ids
    └── provider[registry.terraform.io/hashicorp/random]
The old way, and why it was dropped: a module that configures its own provider. Terraform calls it a legacy module. I tested the legacy module in two forms. With a provider block with NO arguments (provider "local" {}), for_each on the module worked, and even removing one instance planned correctly: Terraform 1.16.5 is tolerant there. With a provider block that has an ARGUMENT (the tls provider with a proxy setting, which needs no account) terraform init itself refuses the module as soon as the caller uses for_each:
Error: Module is incompatible with count, for_each, and depends_on
  on main.tf line 3, in module "legacy":
   3:   for_each = toset(["a", "b"])
The module at module.legacy is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count, for_each,
or depends_on arguments.
If you also control the module "./modules/legacytls", consider updating this
module to instead expect provider configurations to be passed by its caller.
The same module called ONCE (no for_each) initialized fine. Moral: do not put provider configuration in a reusable module. If you are handed one that does, the error above tells you the fix (move the provider block to the caller).

Many copies: for_each and count

A module call takes for_each or count just like a resource, and makes one instance of the whole module per element. With for_each over a map of sites:
locals {
  sites = {
    shop = { port = 8080, pages = { index = "Welcome to the shop" } }
    blog = { port = 8081, pages = { index = "Read the blog" } }
  }
}

module "site" {
  source   = "./modules/site"
  for_each = local.sites

  name  = each.key
  port  = each.value.port
  pages = each.value.pages
  owner = { team = "web" }
}

output "urls" {
  value = { for k, m in module.site : k => m.url }
}
$ terraform apply -auto-approve
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

urls = {
  "blog" = "http://blog.example.org:8081/"
  "shop" = "http://shop.example.org:8080/"
}
$ terraform state list
module.site["blog"].local_file.conf
module.site["blog"].local_file.page["index"]
module.site["shop"].local_file.conf
module.site["shop"].local_file.page["index"]
Each instance has its own address, keyed by the map key. Changing the map:
a) add a third site (docs):
  # module.site["docs"].local_file.conf will be created
  # module.site["docs"].local_file.page["index"] will be created
Plan: 2 to add, 0 to change, 0 to destroy.
b) remove the middle one (blog): only blog is touched.
  # module.site["blog"].local_file.conf will be destroyed
  # (because module.site["blog"] is not in configuration)
  # module.site["blog"].local_file.page["index"] will be destroyed
  # (because module.site["blog"] is not in configuration)
Plan: 0 to add, 0 to change, 2 to destroy.
c) rename a key (docs to manual): the old key is destroyed and the new one created. Terraform cannot know they are "the same".
Plan: 2 to add, 0 to change, 2 to destroy.
(for c, a moved block would avoid the destroy: next but one section.) With count the instances are numbered, and that is the trap. Three sites from a list:
locals {
  names = ["shop", "blog", "docs"]
}

module "site" {
  source = "./modules/site"
  count  = length(local.names)

  name  = local.names[count.index]
  owner = { team = "web" }
}
$ terraform state list | grep conf
module.site[0].local_file.conf
module.site[1].local_file.conf
module.site[2].local_file.conf
Now remove blog, the middle name, from the list:
  # module.site[1].local_file.conf must be replaced
  # module.site[1].local_file.page["index"] must be replaced
  # module.site[2].local_file.conf will be destroyed
  # module.site[2].local_file.page["index"] will be destroyed
Plan: 2 to add, 0 to change, 4 to destroy.
Removing ONE site from the list rebuilt the one after it (index 1 now means docs, and the old docs was index 2) and destroyed the last: 4 destroys and 2 creates, where for_each needed 2 destroys. Use for_each with stable keys for anything that has a name; use count only for "N identical things".
Targeting a single instance works, with a warning that you should not make a habit of it:
$ terraform plan -target='module.site["shop"]'
No changes. Your infrastructure matches the configuration.
Warning: Resource targeting is in effect

One more rule from the error section: a module with a provider block that has arguments cannot be used with count or for_each at all.

One module feeding another

Modules compose: the output of one is an argument of the next. A module namer that returns a random pet name, and the site module using it:
module "namer" {
  source = "./modules/namer"
}

module "site" {
  source = "./modules/site"
  name   = module.namer.name
  owner  = { team = "web" }
}
$ terraform plan
  # module.namer.random_pet.n will be created
  # module.site.local_file.conf will be created
      + filename             = (known after apply)
  # module.site.local_file.page["index"] will be created
      + filename             = (known after apply)
Plan: 3 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve
Outputs:

url = "http://catfish.example.org:80/"
Terraform worked out the order itself, because the second module uses an output of the first (it is a dependency, no depends_on needed). The plan shows "known after apply" for everything that depends on the random name. There is a catch in that. The site module's name has a validation rule, and a rule cannot be checked against a value that does not exist yet. I changed the namer to return the name in upper case (shout = true):
$ terraform plan
Plan: 3 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve
Error: Invalid value for variable
  on main.tf line 8, in module "site":
   8:   name   = module.namer.name
    ├────────────────
    │ var.name is "SHEEPDOG"
name must start with a letter and have 2 to 21 lowercase letters, digits or
hyphens.
This was checked by the validation rule at modules/site/variables.tf:5,3-13.
In a fresh directory with no state the plan was happy, and the apply failed after it had already created the first module's resource:
$ terraform apply -auto-approve
module.namer.random_pet.n: Creation complete after 0s [id=manatee]
Error: Invalid value for variable
    │ var.name is "MANATEE"
$ terraform state list
module.namer.random_pet.n
(a second run, which is why the names in the two blocks differ.) And from then on every plan fails the same way, because the name is now known: it sits in the state. Validation of a value that is unknown at plan time happens at apply, so a green plan is not a promise that the validation rules will pass, and a failed apply leaves a half-built result behind.

Renaming and moving: the moved block

Refactoring is where modules hurt. The address of every resource includes the module call's name, so renaming the call looks like "destroy everything, create everything" to Terraform. Starting from a module "site" that has been applied (3 resources), I renamed the call to module "web" and changed nothing else.
First surprise: even this needs init.
$ terraform plan
Error: Module not installed
$ terraform init
(init exit=0)
Then the plan:
  # module.site.local_file.conf will be destroyed
  # module.site.local_file.page["about"] will be destroyed
  # module.site.local_file.page["index"] will be destroyed
  # module.web.local_file.conf will be created
  # module.web.local_file.page["about"] will be created
  # module.web.local_file.page["index"] will be created
Plan: 3 to add, 0 to change, 3 to destroy.
For files that is annoying; for a database it is a disaster. The fix is a moved block, which tells Terraform that the old address and the new one are the same thing:
moved {
  from = module.site
  to   = module.web
}
$ terraform plan
  # module.site.local_file.conf has moved to module.web.local_file.conf
  # module.site.local_file.page["about"] has moved to module.web.local_file.page["about"]
  # module.site.local_file.page["index"] has moved to module.web.local_file.page["index"]
Plan: 0 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve
Plan: 0 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list
module.web.local_file.conf
module.web.local_file.page["about"]
module.web.local_file.page["index"]
Nothing was destroyed; the state addresses changed. The block can stay in the configuration: the next plan said "No changes". Keeping it until every copy of the state (other environments, other people) has applied the move is my own reasoning about how it works, not something I tested. The same block moves a resource into or out of a module, or between instance keys (the docs-to-manual rename in the for_each section above). Moving resources into or out of a module, and the older command terraform state mv, were not run in the lab.

Testing a module: terraform test

In current versions of Terraform a module can carry its own tests, written in HCL, in files that end in .tftest.hcl. They run with terraform test, from the module's directory, after terraform init. The test file of the site module:
$ cat tests/site.tftest.hcl
variables {
  name  = "t1"
  owner = { team = "web" }
}

run "defaults_are_applied" {
  command = plan

  assert {
    condition     = output.url == "http://t1.example.org:80/"
    error_message = "the default port should be 80, got ${output.url}"
  }
}

run "bad_name_is_rejected" {
  command = plan

  variables {
    name = "Bad Name!"
  }

  expect_failures = [var.name]
}

run "owner_email_default" {
  command = plan

  assert {
    condition     = strcontains(local_file.conf.content, "ops@example.org")
    error_message = "the default e-mail address is missing from the config"
  }
}

run "creates_files" {
  command = apply

  variables {
    pages = { index = "Hi", about = "Us" }
  }

  assert {
    condition     = length(output.page_paths) == 2
    error_message = "expected two pages"
  }

  assert {
    condition     = fileexists(local_file.conf.filename)
    error_message = "the config file was not created"
  }
}

run "deliberately_wrong" {
  command = plan

  assert {
    condition     = output.url == "http://nope/"
    error_message = "this assertion is meant to fail"
  }
}
Each run block is one step. command = plan checks the plan only (nothing is created); command = apply really creates the resources, then destroys them at the end of the file. An assert has a condition and an error message. expect_failures says "this run SHOULD fail, with THIS error": the run passes when the validation rule rejects the bad name. variables { } sets inputs for the file or for one run. A second file uses a mock provider, which fakes the provider so that an apply touches nothing real:
$ cat tests/mock.tftest.hcl
mock_provider "local" {}

variables {
  name  = "m1"
  owner = { team = "web" }
}

run "apply_without_touching_the_disk" {
  command = apply

  assert {
    condition     = length(output.page_paths) == 1
    error_message = "expected one page by default"
  }
}
The result (the last run is wrong on purpose):
$ terraform test
tests/mock.tftest.hcl... in progress
  run "apply_without_touching_the_disk"... pass
tests/mock.tftest.hcl... tearing down
tests/mock.tftest.hcl... pass
tests/site.tftest.hcl... in progress
  run "defaults_are_applied"... pass
  run "bad_name_is_rejected"... pass
  run "owner_email_default"... pass
  run "creates_files"... pass
  run "deliberately_wrong"... fail
Error: Test assertion failed
  on tests/site.tftest.hcl line 56, in run "deliberately_wrong":
  56:     condition     = output.url == "http://nope/"
    ├────────────────
    │ Diff:
    │ --- actual
    │ +++ expected
    │ - "http://t1.example.org:80/"
    │ + "http://nope/"
this assertion is meant to fail
tests/site.tftest.hcl... tearing down
tests/site.tftest.hcl... fail
Failure! 5 passed, 1 failed.
exit=1
Five passed and one failed, and the failure shows what the module actually returned next to what the test expected. The exit code is 1, so a CI job stops on a red test (the "exit=1" line is from my script). The apply-run created real files and removed them at the end; what stayed behind is the empty directory out/t1 (and nothing at all from the mock test: no out/m1), so a test that writes to disk should write to a scratch path. Test what a module PROMISES: the defaults, the rules, the outputs; run the plan-only tests on every change, and the apply tests where creating things is affordable. What they cannot do is prove that a cloud accepts your configuration: a module for a real cloud still needs an integration run. Terraform's test command was also not run against a real cloud in the lab.

Error messages

Each of these ran in the lab.
  Missing required argument ... The argument "owner" is required        the call leaves out a required input
  Invalid value for input variable ... a number is required            the wrong type for an input
  Invalid value for variable ... This was checked by the validation rule at ...    a validation rule rejected the value; the message is the module author's
  Unsupported argument ... An argument named "colour" is not expected here          the module has no such input
  Invalid module source address ... failed to determine your intended installation method    a local path without ./
  Invalid registry module source address ... can't use local directory    version = ... next to a local source
  Unreadable module directory ... no such file or directory           the local path does not exist
  Module not installed ... Run "terraform init"                       a module call was added or renamed since the last init
  Module source has changed ... Run "terraform init"                  the source or ref was edited since the last init
  Output refers to sensitive values                                   a root output carries a sensitive value: add sensitive = true
  Missing required provider configuration ... random.alt              the module needs an extra provider configuration that the caller did not pass
  Module is incompatible with count, for_each, and depends_on         a legacy module (own provider configuration with arguments) called with count or for_each
  Test assertion failed (terraform test)                              an assert condition was false

What was not run in the lab

  - Any cloud provider: everything used the local, random and tls providers. Real modules for networks, clusters and databases add provider credentials, quotas and eventual consistency, which no lab here exercised
  - Publishing to the public registry or a private one (Terraform Cloud and HCP Terraform registries), the naming rule for registry modules, no-code modules, module versioning at scale
  - Source forms other than local path, the public registry and git: HTTP archives, S3 and GCS buckets, Mercurial, git over SSH or with credentials, git submodules
  - depends_on on a module call, and its effect on plan; count = 0 and for_each = {} (modules that vanish)
  - terraform state mv and terraform import with module addresses; moving resources into or out of a module with a moved block
  - Tools around modules: terraform-docs, tflint, checkov, pre-commit hooks, Terragrunt, OpenTofu (its behaviour for the cases above may differ)
  - terraform get -update, terraform init -upgrade on a registry module with a constraint (it was used for a git branch only), module installation behind a proxy or offline
  - terraform test against a real backend, test files with several modules, setup modules and run blocks that use the state of earlier runs
  - Nested modules more than one level deep, and module outputs used in other root modules through terraform_remote_state
  - Validating a module directory on its own: terraform -chdir=modules/site validate answered "Missing required provider" until I ran init in that directory

Recipes

Make a module:                  a directory with main.tf, variables.tf, outputs.tf, versions.tf (and README.md)
Call it:                        module "NAME" { source = "./modules/DIR"   input = value }
Install / refresh modules:      terraform init            (needed after adding, renaming or re-sourcing a call)
Pull a moved branch:            terraform init -upgrade
Which module is where:          cat .terraform/modules/modules.json
Which provider does it need:    terraform providers
List instances in the state:    terraform state list       (module.NAME["key"].type.name)
Evaluate a value:               echo 'module.site.url' | terraform console
Show one output:                terraform output NAME      (a sensitive one is printed when asked for by name)
Many copies, stable:            module "x" { for_each = local.map ... name = each.key }
Rename without destroying:      moved { from = module.old   to = module.new }
Test a module:                  terraform init && terraform test        (tests/*.tftest.hcl)
Pin a git module:               source = "git::https://HOST/REPO.git//DIR?ref=v1.2.3"
Pin a registry module:          source = "NAMESPACE/NAME/PROVIDER"   version = "~> 1.0"
Format and check:               terraform fmt -recursive      terraform validate
Rule of thumb for the module:   inputs documented and validated; outputs documented; required_providers declared; NO provider blocks

Common problems

Symptom                                          Usually
Module not installed ... run terraform init      a module call was added, renamed or re-sourced: run terraform init
Module source has changed                        the source or ref changed since the last init: run terraform init
"source" with no ./ gives Invalid module source  a local path must begin with ./ or ../
version = ... gives Invalid registry module ...  version is for registry modules only: remove it for a local path
A typo in an object input does nothing           unknown keys in an object are dropped, no error. Prefer flat inputs, and read plans for changes you expected
Output refers to sensitive values                add sensitive = true to the root output (and remember that terraform output NAME prints it, and the state holds it)
Files written by a module vanished               it wrote under path.module, which for a git or registry module is the .terraform cache. Write under a path the caller controls
Two machines get different module code           the source uses ?ref=main (a branch), or a version range: use a tag or commit, and know that terraform init -upgrade moves ranges
Renaming a module call wants to destroy everything   the address changed: add a moved block before you apply
Removing one item rebuilt the next               count with a list: use for_each with stable keys
Module is incompatible with count, for_each      the module has a provider block with arguments: move provider configuration to the caller
Missing required provider configuration          the module declares configuration_aliases: pass them with providers = { ... }
Plan is green, apply fails on validation         the value was unknown at plan time (it comes from another resource): validation ran at apply
terraform test leaves empty directories          apply-runs create files and remove them, not the directories: use a scratch path

Tips

Design a module like a function: few inputs, each with a type, a description and, where possible, a validation rule; every output documented.
Declare required_providers and required_version in the module; never configure a provider in it.
Keep modules small and single-purpose; compose them in the root instead of building one giant module with a hundred switches.
Pin what you call: a tag or commit for git, an exact version or ~> for the registry, and read what an update changes before you take it.
Read plans for what did NOT change that you expected to: the silent typo and the dropped key look like "no changes".
Use for_each with stable keys, and put a moved block in any change that renames a module call or a key.
Write tests for the promises of the module (defaults, rules, outputs), run the plan tests on every change, and mock the provider when you want a fast, safe apply test.
Treat the state like a secret store: sensitive values are hidden from screens, not from the file.
Do not commit .terraform/ or state; do commit .terraform.lock.hcl.
Only run Terraform against infrastructure you are authorized to change, and read every plan before you apply it.