Go to content
Infrastructure

When to Use Terraform Modules

You write Terraform configuration for infrastructure your team or company runs. You have a directory of.tf files that creates, say, a VPC, subnets, security groups, a database, and the IAM roles an application needs. Then you need the same setup in a second environment, and you face the standard choice: copy the files, or extract a module.

A module is a directory of Terraform configuration with input variables and output values that another configuration calls. The configuration you run terraform apply on is the root module. Calling a module works like calling a function. You pass variables to it, and the module creates its resources. You read its outputs afterward.

Extract a module when the same set of resources appears in more than one place with the same lifecycle, you can name the single thing the module does, and you can carry the maintenance that comes with it. That maintenance includes designing inputs and outputs. It also covers versioning, testing, documenting, and assigning an owner. If you have one caller, if the resources are still changing shape, or if the abstraction would hide decisions your team needs to make per environment, keep the configuration in the root module and skip the extraction.

What a module buys you

A module hides complexity behind a small interface. HashiCorp’s module creation guidance describes modules as self-contained pieces of infrastructure-as-code that hide underlying complexity, help teams adopt them faster, and make them easier to use. That matters most when the consumer is a different team. A data science team that needs a Kubernetes cluster does not need to learn how to wire subnets, node groups, and IAM roles. It needs a module with variables for instance type and node count. The right level of abstraction follows the consumer: a team running everything on Amazon EKS needs a lower-level interface than a data science team that just wants a cluster.

A module pays off only when more than one configuration calls it. The benefit is durable: when you change the module once, every environment that calls it picks up the change, and one set of defaults applies everywhere.

Consistency is the less obvious benefit. Company-specific modules can wrap AWS resources with mandatory defaults, such as RDS storage encryption enabled and S3 public access disabled, so users never get the chance to forget them. That works well for defaults your organization always wants.

A default is not the same as enforcement. HashiCorp’s Bruno Schaatsbergen said at a HashiConf panel that he is strongly against forcing everyone to use modules, noting that many teams do this to enforce security and compliance standards that belong in organizational policies. A module encodes a sensible default; a policy rejects configuration that violates the rule. If you build a module as a compliance gate, you will be tempted to add switches that let people turn the safety off.

When a plain root module is the better default

Start with the root module and treat extraction as a decision. Before you extract, ask what the module actually saves. If only one configuration will ever call it, the module adds an interface without removing any duplication. You still maintain the same resources, and now you also maintain variables, defaults, documentation, and a version.

Wait until the resources stop changing. Early in a project you will discover that the database needs a different instance class, the load balancer needs another listener, or the security group rules were wrong. You route those changes through an interface you are still designing, so every iteration costs an extra step. Practitioner guidance suggests using local modules, modules stored in a subdirectory of your repository, for the first few months of a project, then extracting the patterns that actually repeat into separate versioned repositories.

The strongest reason to stay in the root module is that the abstraction would hide decisions. HashiCorp recommends a module MVP covering at least 80% of use cases and never coding for edge cases. If your second caller needs a different resource, and you add a conditional toggle to serve it, you violate that rule. You end up with a module that creates different resources depending on a flag, which is harder to reason about than the two plain configurations you started with.

Before extracting, test whether you can name the one thing the module does. Schaatsbergen said a module should do one thing and do it well. If the module would need to be everything to both teams, keep the configuration in the root module until the pattern is clearer.

Where module boundaries break down

The common failure is a module that exposes too many inputs and options. A practitioner review of production Terraform setups identified six anti-patterns, and the first is over-abstraction: a module stuffed with enable_xxx toggles that conditionally create resources. Each toggle multiplies the combinations the module can produce, and a reader can no longer tell what resources a given call actually creates.

The second anti-pattern is variable explosion. A composition module calls several smaller modules and can pass through every variable its children accept, dumping the full variable count onto its own interface. Callers must then supply values for things the composition module does not care about.

Circular dependencies are the third. A VPC module that needs a security group ID while the security group module needs the VPC ID cannot be applied in either order. You fix it by extracting the shared resource, the VPC, to an upper layer and passing it down.

Deep nesting causes the same problem in a different form. One practitioner describes updating a route table module as requiring a version bump in every parent module and route-table parameters passed through modules that do not care about them. HashiCorp recommends keeping the module tree flat, with only one level of child modules, and using expressions to describe relationships between modules, a style it calls module composition. Do not nest primary modules more than two deep; simple utility modules such as tag definitions are the exception.

Two coupling problems sit at the state level. Terraform keeps a state file, a record of every resource it manages, so that apply can compare the real infrastructure with the configuration. When several modules share one state file, you can affect resources owned by another module when you run terraform destroy on one of them, and all the modules share a single lock during apply. A module without an owner becomes orphan code: nobody dares to change it, and it accrues as technical debt.

External modules have a trust problem: a nested external module can change in a way that affects the parent module without any change to the parent’s calling code or version, and that breaks the calling code’s trust. Revisions to external modules need to be backwards compatible, and callers need a way to pin what they depend on.

What a good module boundary looks like

HashiCorp’s enterprise patterns guidance suggests scoping a module by three criteria: encapsulation, privileges, and volatility. Encapsulation groups infrastructure that is always deployed together, so one apply creates and updates the whole unit. The privileges criterion keeps the module inside a single privilege boundary, so you do not create a module that must act as both a network admin and an application deployer. Volatility separates long-lived infrastructure from short-lived, so a weekly workload change does not force a re-plan of the VPC.

The strongest structural advice is dependency inversion: a module should receive its dependencies from the root module instead of creating and managing its own copies, so the same modules can connect in different ways. Concretely, a security group module should take the VPC ID as an input. The root module decides whether to create the VPC or look it up with a data source, a read-only query into existing infrastructure, and the same security group module works in either case. Relatedly, do not write a module that checks whether an object exists and creates it if it does not; accept the object as an input and let the caller decide.

One practitioner recommends defining module granularity around change frequency: networking primitives change rarely, while workload configuration might change weekly. If you bundle both into one module, you replan the stable networking resources every time the workload changes, and you expose them to the risk of that apply.

Two boundary patterns hold up well in practice. The app-resource module groups the assorted ad-hoc resources for one application, such as IAM roles, SSL certificates, and random strings for secrets, so environments reuse them instead of copy-pasting them. A data-only module contains no resource blocks, only data sources that retrieve information, which raises the abstraction level by hiding exactly how the data is fetched.

For sizing, one practitioner’s client work suggests base modules with 5 to 15 variables and 3 to 8 resources, composition modules with 15 to 30 variables that call 3 to 5 base modules, and an environment layer that calls 2 to 4 composition modules. The same source recommends splitting a module when its variable descriptions contain more than three "when X is true, enable Y" patterns. These are calibration points from real deployments, not a HashiCorp standard.

The maintenance contract: testing, versioning, and ownership

A module you do not test, version, and own will rot faster than the duplication it replaced. The first commitment is testing. You can write automated tests for Terraform modules with the terraform test framework, available in recent Terraform versions, or with Terratest, a Go library that runs terraform apply against real infrastructure and asserts on the results, for example that the load balancer ARN and instance IDs match what the module should have created. Practitioner guidance calls for running these tests in CI so a module change that does not pass cannot merge to main. At the same panel, Drew Mullen of River Point Technology noted that module users spend most of their time in the examples directory, so he makes sure the tests cover the example configurations and validate the major use cases.

Versioning is the second commitment. For shared modules, move them out of a monorepo into their own Git repositories so each environment can pin the version it deploys. You control that by referencing a branch or tag in the module source. Registry version constraints such as ~> 1.1.0 do not pin precisely: everyone must run terraform init -upgrade at the same time to converge on the same versions.

Ownership is the third. HashiCorp’s guidance for publishing modules is explicit about the operating model: keep modules in source control, assign each one an owner, review changes with pull requests before release, and publish a changelog for each version. A module without an owner is an orphan; nobody dares to touch it, and it becomes technical debt.

The inputs and outputs are the interface contract, and they deserve the same care as the resources inside. Required inputs should have no default, so the caller must supply them deliberately. Optional inputs should carry sensible defaults. Schaatsbergen explained that a default of null for an optional input causes Terraform to omit the argument entirely, so the provider, the plugin that manages your cloud resources, applies its own default. An empty string, by contrast, is passed as a value and can change provider behavior.

Publish the outputs callers will consume and document them in the module README. HashiCorp recommends maximizing outputs even when you have no current use for them, because end users often feed outputs from one module into the next. The counterweight is that every output is a coupling point, so expose the module’s results, not its internal implementation.

A decision rule for extracting a module

Extract a module when four things hold. The same set of resources appears in more than one place with the same lifecycle. You can name the single thing the module does. The boundary follows the change frequency of the pieces inside it, so stable resources do not get replanned because a workload changed. And you can assign an owner who will test and version it.

With one caller, or while the resources are still changing shape, keep the configuration in the root module. Also keep it there if the module would hide per-environment decisions behind variables that callers cannot meaningfully answer. A module with an enable_xxx toggle for every difference is the signal that the boundary is wrong.

When you do extract, follow the MVP discipline: build for the 80% case, keep conditionals out, expose the outputs callers will consume, and document the inputs. The first version is the cheapest time to get the interface right, because every caller you add later locks it in.

More in Infrastructure Back to top ↑