Terraform modules others actually want to use
Reusable, documented, versioned, and not over-engineered.
Reusable, documented, versioned, and not over-engineered.

Every platform team eventually builds a set of internal Terraform modules. A standard way to create a database, a bucket, a network, a cluster. The idea is great: encode the company's best practices once, and let everyone reuse them.
In practice, many internal modules end up either ignored (teams copy resources instead) or hated (so many options that nobody understands them). Here's what makes a module people actually want to use.
A good module does one thing well, with an opinion. Not "a wrapper around every argument of aws_db_instance", but "a PostgreSQL database configured the way we run databases here": encrypted, backed up, monitored, in the right network, with the right tags.
If the module just passes through every argument of the underlying resource, it adds no value. Teams might as well use the resource directly.
A good module removes decisions. A bad module just moves them to a different file.
modules/rds-postgres/
main.tf
variables.tf
outputs.tf
versions.tf
README.md
examples/
basic/
main.tf
tests/
defaults.tftest.hcl
versions.tf pins the required Terraform version and provider versions.examples/ shows real usage, and doubles as a test fixture.tests/ with terraform test files.Few required inputs, good defaults for the rest. The basic example should need only a handful of lines:
module "orders_db" {
source = "git::https://github.com/acme/terraform-modules.git//rds-postgres?ref=v2.3.0"
name = "orders"
environment = "production"
size = "medium"
}
Validate inputs so mistakes fail early with a clear message:
variable "size" {
description = "Database size: small, medium or large."
type = string
default = "small"
validation {
condition = contains(["small", "medium", "large"], var.size)
error_message = "size must be one of: small, medium, large."
}
}
Translating size = "medium" into a specific instance class inside the module also means you can change the underlying instance type later without every team changing their code.
Useful outputs: IDs, endpoints, ARNs, security group IDs, the name of the secret holding credentials. Think about what the next resource will need.
Use precise types: object({...}) with optional attributes instead of a generic map(any).
Modules change. Users need stability. So:
v1.4.0, v2.0.0.?ref=v2.3.0 for Git, or version = "~> 2.3" for a registry). Never point at main.Nobody wants to maintain a README by hand. terraform-docs generates the inputs and outputs tables from your code:
terraform-docs markdown table --output-file README.md --output-mode inject .
Run it in a pre-commit hook or CI, and the documentation stays in sync. Add a short human-written section on top: what the module is for, what it does by default, and a minimal example.
Even a few tests help a lot:
terraform test to check defaults: encryption on, deletion protection on, tags present.The most common mistake I see: modules wrapping modules wrapping modules, with dozens of variables, feature flags for every possible use case, and count tricks everywhere.
Signs you've gone too far:
It's fine for a module to say no. If a team has a genuinely unusual need, they can use the resource directly or you can create a second, separate module. Not every use case belongs in the same one.
A clear opinion, few required inputs, validated variables, useful outputs, generated docs, real versions and a few tests. Built for the common case, with the humility to not cover everything.
My take: build modules that make the right way short and obvious. If people copy resources instead, the module isn't helping them yet.
— Desly