Writing a Reusable Module
The idea in one sentence
Section titled “The idea in one sentence”A module is just any directory of .tf files — the one you run terraform apply in is the root module, and any directory you call into with a module block is a child module, so there is nothing structurally special about “being a module.”
There is no module keyword — only usage
Section titled “There is no module keyword — only usage”This trips people up the first time they hear it: Terraform has no special file, no module.tf, no magic marker that turns a directory into a module. Every directory of .tf files you have ever written is already a module. The directory where you run terraform init and terraform apply directly is called the root module. The moment you reference some other directory of .tf files from inside a module block, that other directory becomes a child module — but its own .tf files look exactly the same as any root configuration’s:
# root main.tf — calling a child modulemodule "bucket" { source = "./modules/s3-bucket" bucket_name = "acme-app-logs"}That is the entire distinction: a child module is a directory of Terraform configuration that some other configuration references by address. Nothing inside the module’s own .tf files has to change to make this true — the same directory could be applied directly as a root module if you cd into it and ran terraform apply there yourself.
Why a module’s file layout mirrors a root config
Section titled “Why a module’s file layout mirrors a root config”Because a module is a Terraform configuration, it follows the exact same three-file convention you already use for root configs, just with a different audience in mind:
variables.tf— the module’s inputs. This is its public API surface: what a caller must or may provide.main.tf— the resources the module actually manages.outputs.tf— the values the module exposes back to whoever called it.
This is worth internalizing: a module is not a special templating construct layered on top of Terraform. It is a normal Terraform configuration that happens to be parameterized by variables.tf on the way in and by outputs.tf on the way out.
variable "bucket_name" { description = "Name of the S3 bucket to create" type = string}resource "aws_s3_bucket" "this" { bucket = var.bucket_name}output "arn" { description = "ARN of the created S3 bucket" value = aws_s3_bucket.this.arn}A caller wires this up with a module block, treating bucket_name as the input and module.<name>.arn as the output it can reference elsewhere:
# root main.tfmodule "logs_bucket" { source = "./modules/s3-bucket" bucket_name = "acme-app-logs"}
output "logs_bucket_arn" { value = module.logs_bucket.arn}Designing a module with one clear responsibility
Section titled “Designing a module with one clear responsibility”The single biggest design mistake with modules is scope creep: a module that starts as “a VPC” and slowly grows an IAM role here, an EC2 instance there, a Lambda function somewhere else, until nobody can reuse it for anything but the one thing it was originally built for. Aim for a module that does one well-scoped job — “a VPC with public and private subnets,” not “all of our AWS networking, IAM, and compute.”
Two practical habits keep a module reusable instead of accidentally specific to one caller:
- Sensible defaults for optional inputs. If most callers want the same value for an input, give that input a
defaultinvariables.tfso a simple caller does not have to specify it at all. - Resist over-parameterizing on day one. It is tempting to expose every field a resource supports as a variable “just in case.” Every variable you add is a permanent piece of the module’s public API that you now have to document and maintain. Add an input when a real caller actually needs that variation — not preemptively for a hypothetical one.
variable "bucket_name" { description = "Name of the S3 bucket to create" type = string}
variable "force_destroy" { description = "Allow bucket to be destroyed even if it still contains objects" type = bool default = false}Here force_destroy has a sensible default — most callers never touch it — while bucket_name has none, because every caller genuinely needs a different bucket name and there is no reasonable default to guess.
The rule a reusable module must follow: never declare your own provider
Section titled “The rule a reusable module must follow: never declare your own provider”A module intended for reuse should almost never contain a provider block with real configuration in it. Per the official guidance (developer.hashicorp.com/terraform/language/modules/develop/providers): “Modules intended for use by other modules should not contain provider blocks. Modules with their own provider configurations are incompatible with arguments like for_each, count, and depends_on.” That is not a style preference — it is enforced, with a real error. Proof: a child module with a genuine, non-empty provider "aws" { region = "us-east-1" } block, called with count:
# @expect-error Module is incompatible with count, for_each, and depends_onterraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } }}
provider "aws" { region = "us-east-1"}
resource "aws_s3_bucket" "this" { bucket = "acme-example-bucket"}# @expect-error Module is incompatible with count, for_each, and depends_onmodule "bucket" { source = "./modules/bucket" count = 2}Real terraform init output on Terraform 1.16.4:
$ terraform initError: Module is incompatible with count, for_each, and depends_on
on main.tf line 12, in module "bucket": 12: count = 2
The module at module.bucket is a legacy module which contains its own localprovider configurations, and so calls to it may not use the count,for_each, or depends_on arguments.
If you also control the module "./modules/bucket", consider updating thismodule to instead expect provider configurations to be passed by itscaller.Note the exact wording: this is the current (1.16.4) error text, which differs from the “Module does not support count” wording HashiCorp’s own docs page still shows from an older release — reviewed content that quotes a Terraform error should always be re-verified against the pinned version rather than trusted from memory or an older doc snippet, exactly the gap this proof closes. The fix is configuration_aliases: the module declares it needs a provider passed in under a local alias, and never configures one itself.
# Fixed: modules/bucket/main.tf never owns a provider blockterraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" configuration_aliases = [aws.this] } }}
resource "aws_s3_bucket" "this" { provider = aws.this bucket = "acme-example-bucket"}# Fixed: root main.tf passes the provider explicitlymodule "bucket" { source = "./modules/bucket" count = 2 providers = { aws.this = aws }}Real terraform plan output after the fix, with mock AWS credentials (no real account needed):
$ terraform planPlan: 2 to add, 0 to change, 0 to destroy.providers = {} in the calling module block is how the caller hands the module a specific (possibly aliased) provider configuration explicitly, instead of the module silently inheriting the default provider the way it would if it declared no provider block and no configuration_aliases at all. A module written this way works identically whether it is called once, with count, or with for_each — because it never owns a provider configuration to begin with.
flowchart LR
subgraph root["Root module"]
rmain["main.tf calls module.bucket"]
end
subgraph child["Child module: modules/s3-bucket"]
vars["variables.tf (inputs)"] --> main["main.tf (resources)"]
main --> outs["outputs.tf (values exposed)"]
end
rmain -->|source = ./modules/s3-bucket| vars
outs -->|module.bucket.arn| rmain Mental Model
Section titled “Mental Model”A module is reusable in direct proportion to how little it assumes about its caller. File layout, sensible defaults, and resisting over-parameterization all serve that goal at the configuration level; the provider rule proven above serves it at the provider level — a module that owns its own provider block has quietly assumed “I will only ever be called once, with the default provider,” and that assumption becomes a real, named error the instant a caller tries count, for_each, or depends_on. configuration_aliases plus a caller-side providers = {} map is how a module states its provider dependency explicitly instead of assuming it.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| One clear responsibility per module | Reusable across many callers without modification | More modules to navigate; a caller may need to wire several together (covered in module-composition-and-sources.mdx) |
| Sensible defaults on optional inputs | Simple callers need only the required inputs | A default can hide a real decision a caller should have made consciously |
configuration_aliases instead of an owned provider block | Works with count/for_each/depends_on — proven above, the alternative hard-fails with a real error | The caller must remember to pass providers = {}; forgetting it is its own error to debug |
Common Misconceptions
Section titled “Common Misconceptions”- “A module needs its own
providerblock to know which AWS account or region to use.” Proven above: the opposite is true for a reusable module — an owned provider block is exactly what makescount/for_each/depends_onfail with a real, named error. The provider should come from the caller. - “The count/for_each restriction on modules with providers is just a style lint, not a hard error.” Proven above with a real
terraform initfailure on Terraform 1.16.4: “Module is incompatible with count, for_each, and depends_on.” - “Any provider block inside a module triggers this error, even an empty one.” Not quite — proven by contrast in this course’s harness runs: a legacy empty
provider "aws" {}“proxy” block only produces a deprecation warning and still plans successfully; it is a genuinely configured provider block (real arguments) that triggers the hard incompatibility error. - “You can always trust an error message’s exact wording from memory or from a docs page you read a while ago.” Proven above: the officially documented example text (“Module does not support count … contains a nested provider configuration”) does not match the real Terraform 1.16.4 output (“Module is incompatible with count, for_each, and depends_on”) — always re-verify against the pinned version.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a generated module intended to be reusable, check for:
- A
providerblock with real, non-empty arguments inside a module directory — proven above, this silently breaks the moment a caller triescount,for_each, ordepends_onon that module, often far from where the provider block was originally written. - A module that hardcodes an AWS region, account-specific value, or credential-adjacent setting instead of accepting it (or an aliased provider) from its caller.
- Every field a resource supports exposed as a module variable “for completeness,” rather than added when a real caller needs that specific variation — proven above, each one is a permanent piece of public API to maintain.
- A required variable with no
description, making the module’s public API harder for a caller to use correctly without reading the resource block itself.
🐛 Spot the bug
terraform {required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" }}}
provider "aws" {region = "us-west-2"}
resource "aws_sqs_queue" "this" {name = var.queue_name}Show the answer
Proven above with a real terraform init failure: this module owns a genuine, configured provider "aws" block, which per the official guidance (“Modules intended for use by other modules should not contain provider blocks”) makes it incompatible with count, for_each, and depends_on the moment any caller tries to use one of those on the module block — the exact, real error is “Module is incompatible with count, for_each, and depends_on … contains its own local provider configurations.” Worse, it silently hardcodes us-west-2, so every caller gets that region regardless of their own provider configuration, with no way to override it. The fix is configuration_aliases = [aws.this] in required_providers, referencing provider = aws.this on the resource, and no provider block in the module at all — the caller then supplies the actual provider via providers = { aws.this = aws }, proven above to plan cleanly even with count = 2.