
Every team has one: the module nobody wants to touch. It grew a flag for each new edge case, it reaches into a remote state file three directories away, and its README still lists inputs that were removed last spring. Modules are supposed to make infrastructure easier to reason about. A badly designed one does the opposite, hiding decisions instead of packaging them.
None of the mistakes below are exotic. They are the ones that show up again and again in reviews, usually written by someone competent who was in a hurry.
Designing around decisions, not resources
The first sign of trouble is a module that wraps a single resource. If the entire module body is one aws_s3_bucket and eleven pass-through variables, the caller has gained an indirection and lost nothing else. They now pin a version, learn your variable names, and debug one extra layer when something goes wrong.
A module earns its place when it encodes a decision. A bucket plus its policy, logging configuration, lifecycle rules and public-access block, wired in the one arrangement your organisation actually approves — that is worth reusing. A module that provisions an entire platform, with sixty variables and a dozen conditionals, is the opposite failure. Prefer several small modules composed at the root.
A useful test: can you describe what the module guarantees in one sentence? If the answer is "it makes a bucket", it is an alias, not a module.
Variable design that fights the caller
Booleans multiply
Three boolean variables create eight possible configurations, most of which have never been tested and some of which are invalid. Two of them will eventually contradict each other and the module will quietly do something odd. Where the states are mutually exclusive, use a single string variable with validation and let the module decide the details.
So instead of enable_logging, enable_encryption and enable_versioning, expose one tier variable with values such as basic, standard and locked. Callers pick an intent, not a set of switches.
Defaults, naming and types
Require the values that have no safe default — name, environment, owner — and default the rest. Avoid default = "": an empty string gives the caller no way to tell whether they forgot or meant it, and it usually produces a confusing API error later. Terraform's optional() with nullable = false expresses this properly.
Name variables after the domain rather than the resource attribute. retention_days survives a change of storage backend; s3_lifecycle_expiration_days does not.
Validation is your cheapest bug filter
Terraform runs validation blocks at plan time, before anything is created. It costs a few lines and catches typos that would otherwise surface as a broken environment. A type constraint is the first layer — list(string) beats a comma-separated string that someone has to split — and a condition is the second:
- condition = contains(["dev", "staging", "prod"], var.environment) to catch invented environment names.
- can(regex("^[a-z][a-z0-9-]{2,30}$", var.name)) where a naming standard or provider limit applies.
- can(cidrhost(var.cidr_block, 0)) to reject malformed CIDR ranges early.
Write error messages that tell the reader what to do, not just what went wrong. "environment must be one of dev, staging or prod" is better than "invalid value". Note that a single validation block cannot compare two variables; for cross-field rules use a precondition on a resource or a check block, and accept that it reports later than you would like.
Mark anything secret with sensitive = true. It does not encrypt the value in state, but it keeps it out of plan output and CI logs.
Coupling: when a module knows too much
Tight coupling is the most expensive habit on this list because it is the hardest to unpick later. Warning signs include a provider block inside the module, a hardcoded region or account ID, a data source pointing at another team's state file, and a module that calls a sibling module through a relative source path.
The root module should own configuration and ordering. Your module should declare what it needs in required_providers, accept identifiers, ARNs or objects as inputs, and return identifiers. If it genuinely has to act in a second region or account, use configuration_aliases and let the caller pass the aliased provider in.
Resist depends_on as a fix. It hides an undeclared dependency; passing the network ID in as a variable states it plainly and makes the module testable in isolation. Pin your source references to a tag or version rather than a moving branch, so a change elsewhere cannot alter your module between two plans on the same day.
Outputs: expose the interface, not the internals
Outputs are your public API. Returning the whole resource object tempts consumers to reach into attributes you never intended to support, and your next refactor becomes a breaking change. Output the handful of identifiers people actually reference — an ID, an ARN, an endpoint, a DNS name — and nothing more.
Two smaller traps: mark secret outputs as sensitive, and check what happens when a resource has count = 0. An output that throws an error in a valid configuration is a bug in the module, so guard it with one() or try() rather than leaving the caller to work around it.
Versions, docs and a runnable example
A module without versioning is a shared branch, not a module. Tag releases, keep a changelog with upgrade notes for breaking changes, and generate the input and output tables from the code so the documentation cannot drift away from reality. Hand-written tables always do.
Ship an examples directory containing at least one configuration that uses the module the way a real caller would, and run it in CI with terraform init, validate and plan. That single step catches most interface breaks before anyone else notices them. Test in two environments if you can; behaviour that only ever sees a sandbox tends to surprise people in production.
If the example is annoying to maintain, the module is annoying to use. The friction you feel as the author is the friction every caller feels.
A short checklist before you publish
- Can you describe what the module guarantees in one sentence?
- Does every variable have a type, a sensible default where safe, and validation where a wrong value is plausible?
- Are there fewer than three boolean variables?
- Does the module configure its own providers or reach outside its inputs for identity?
- Do outputs expose identifiers rather than entire resources?
- Is there a tagged release, a changelog and an example that runs in CI?
Run through that list once and you will avoid most of the pain. None of it requires unusual skill — just the discipline to treat a module as a product with a small, honest interface rather than a folder of resources with a variable for every eventuality.
Photo: fancycrave1 / Pixabay


