Modules: Reusable Infrastructure
Modules: Reusable Infrastructure
At Google and Amazon, no team provisions an S3 bucket or a VPC by writing raw resource blocks from scratch. They consume an internal module — a vetted, tested, company-standard package that encodes every security baseline and tagging requirement. Modules are the mechanism that scales Terraform from personal scripts to organisation-wide infrastructure platforms. When you reach the point where copy-pasting resource blocks between directories starts to feel wrong, modules are the answer.
What a Module Actually Is
A Terraform module is simply a directory of .tf files. Every Terraform project is already a module — the directory you run terraform init from is called the root module. When you call another directory (or a remote package) from your root, that is a child module. There is no special syntax to declare a module; the boundary is the filesystem path.
The three files you will almost always find in a well-structured module are:
main.tf— the resource definitionsvariables.tf— input variable declarationsoutputs.tf— values the module exposes to its caller
Optionally: versions.tf (required provider constraints) and README.md (human documentation, mandatory in the Terraform Registry).
Writing a Local Module
Start with a minimal but realistic example: a reusable S3 bucket module that enforces versioning, server-side encryption, and public-access blocking — the baseline every production bucket should have.
The module wraps four resources behind a two-variable interface. A consumer needs to know nothing about SSE or public-access blocks — those decisions are encoded once, inside the module.
Consuming a Module with a module Block
In the root module, wire the child in with a module block. The source argument tells Terraform where to find the module. The remaining arguments map to the child's variable declarations.
After adding a new module block, you must run terraform init before terraform plan. init downloads or copies module sources into the .terraform/modules/ cache. Forgetting this step is the single most common "module not found" error.
.terraform/ to Git. This directory contains downloaded providers and module source copies — it can be hundreds of megabytes and is fully reproducible by running terraform init. Add .terraform/ and *.tfstate to your .gitignore. The only state file that may live in Git is a *.tfstate used for local development in a personal sandbox — and even that is discouraged.
Module Sources and Versioning
The source argument accepts five kinds of sources, and the choice has real operational consequences:
- Local path (
./modules/vpc) — fastest inner loop, no network, no versioning. Use for modules that live in the same repository as the root configuration (monorepo style). Changes are picked up immediately on nextinit. - Terraform Registry (
hashicorp/consul/aws) — the public registry atregistry.terraform.io. Versioned viaversion. Community modules liketerraform-aws-modules/vpc/awsare the industry-standard starting point. Always pin a version. - GitHub / GitLab (
git::https://github.com/org/repo.git//subdir?ref=v1.2.3) — useful for private modules before you set up a private registry. Pin to a tag or commit SHA, never a branch name in production. - Private registry (Terraform Cloud, Spacelift, Env0) — the enterprise pattern. Same
sourcesyntax as the public registry but authenticated. Enables semantic versioning, automated testing pipelines, and access controls on module versions. - S3 / GCS bucket (
s3::https://s3.amazonaws.com/bucket/modules/vpc.zip) — a lightweight private distribution mechanism without a full registry.
= 5.8.0 pins exactly (fragile — blocks security patches). ~> 5.8 (pessimistic constraint) allows patch and minor bumps within the same major, which is the recommended default for third-party modules. >= 5.0, < 6.0 is an explicit range useful when you know a breaking change is coming in 6.0. Run terraform get -update to pull the latest allowed version into the module cache.
Module Composition and Output Chaining
Modules become powerful when their outputs wire together. The pattern is: one module provides a resource, a second module consumes its output as an input variable, and Terraform builds the dependency graph automatically.
In the code block above, module.vpc.vpc_id and module.vpc.private_subnets reference outputs exported by the VPC module. Terraform sees this cross-module reference and guarantees the VPC is fully provisioned before it attempts to create the EKS cluster. You never need to manage depends_on for cross-module output references — the graph is implicit.
Testing and Versioning Your Own Modules
When you maintain modules that other teams consume, you need a release discipline. The standard pattern mirrors how you would version a library:
- Keep modules in a dedicated repository (or a well-defined subdirectory of a monorepo). Do not mix module code with root-level configuration.
- Tag every release with a semver tag (
v1.0.0,v1.1.0). Consumers pin to a tag, not a branch. - Write automated tests with Terratest (Go-based) or Terraform's built-in
terraform test(HCL-based, available since Terraform 1.6 / OpenTofu 1.7). Tests provision real infrastructure in an isolated AWS account, assert outputs, and destroy everything on completion. - Run the test suite in CI on every pull request before merging and tagging.
This pipeline — PR → CI tests real infra → merge → tag → consumers update version pin — is the standard at Gruntwork, Hashicorp, and large platform engineering teams that publish internal module catalogs to thousands of engineers.