Text size
Tried & Tested6 min read

Terraform modules others actually want to use

Reusable, documented, versioned, and not over-engineered.

Terraform modules others actually want to use
AI-generated editorial illustration

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.

Start with a clear purpose

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.

A clean structure

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.

Inputs, outputs and validation

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).

Versioning and a private registry

Modules change. Users need stability. So:

  • Tag releases with semantic versions: v1.4.0, v2.0.0.
  • Always reference a version (?ref=v2.3.0 for Git, or version = "~> 2.3" for a registry). Never point at main.
  • Write a changelog, and mark breaking changes clearly. A breaking change means a new major version.
  • Publish to a private registry if you can: HCP Terraform, GitLab, Artifactory, Spacelift and others offer them. It gives you version constraints, discovery and documentation in one place.
  • Let Renovate update module versions in consuming repositories.

Docs that write themselves

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.

Testing modules

Even a few tests help a lot:

  • Plan-based tests with terraform test to check defaults: encryption on, deletion protection on, tags present.
  • Validation tests that check bad inputs are rejected.
  • An apply test in a sandbox account for critical modules, run on release rather than every commit.
  • Static checks (tflint, Checkov) on the module itself.

When to stop abstracting

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:

  • The module has more variables than the resources it creates have arguments.
  • Nobody can explain what happens when you set a particular flag.
  • Teams fork the module because changing it is too scary.

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.

So, what makes a great module?

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