Designing, Publishing, and Consuming Reusable Modules
Contracts
A module is an interface: input variables, outputs, and a provider contract. Every module should declare required_providers (with configuration_aliases for multi-region patterns) and required_version, and should never contain a backend block or a provider block - those belong to the root module. Callers pass alternate providers explicitly with providers = { aws = aws.us_east_1 }.
Sources and call sites
A local source must begin with ./ or ../; otherwise Terraform resolves it as a registry address or a URL. The version argument is valid only for registry modules, so pin git sources with ?ref=v1.4.0. Modules accept count and for_each, but keys must be known at plan time - never derive them from resource attributes. Outputs marked sensitive stay sensitive through every consumer.
Publication
Terraform Registry naming is terraform-<PROVIDER>-<NAME>, and versions come from git tags such as v1.2.0, which must be valid semantic versions. A consumable module ships main.tf, variables.tf, outputs.tf, a README, examples/ that double as fixtures, and ideally .tftest.hcl files. Commit the lock file for root modules; many teams omit it in shared modules so providers resolve in the consumer. Providers are cached under .terraform/modules, and terraform init -upgrade only updates modules within their declared constraints.
Trap
The classic trap is provider scoping: for_each over a module creates distinct instances, yet each still receives the default provider unless you pass an alias explicitly. The second is expecting terraform init -upgrade to bump a module whose version constraint already pins it - it will not.
Rule of thumb
Design around one responsibility, expose only the variables callers genuinely vary, and prefer a module output over terraform_remote_state for intra-configuration data flow.