MVP Terraform Module Structure for Production Teams: CI and SemVer
MVP Terraform module structure for production teams. Concrete repo scaffolds, SemVer versioning, two-level nesting guidance, and CI checks to enforce...
The canonical Terraform module structure is a root module with main.tf, variables.tf, outputs.tf, and a README.md, plus optional modules/ and examples/ directories for anything more complex. The rule that matters more than the file layout: build narrow, opinionated modules that cover the majority of your typical use cases, and reach for composition instead of piling submodule on top of submodule.
TL;DR:
- Use a minimal module setup with main, variables, outputs, and README files, adding optional directories like examples or modules for sharing.
- Keep resource and module calls in main.tf simple and focused, with variables tightly scoped and well-described to avoid internal complexity.
- Aim for narrow, opinionated modules covering about 80% of common use cases, avoiding over-complexity and conditional sprawl.
- Limit module nesting to two levels, favoring sibling modules at the root for clarity and easier maintenance across teams.
- Lock module versions strictly in production with exact pins, and automate validation, linting, and documentation enforcement through CI pipelines.
Table of Contents
- What Is the Standard Terraform Module Structure?
- What Should Go in Each File?
- How Narrow Should a Terraform Module Be?
- How Deep Should Module Nesting Go?
- How Do You Version and Pin Terraform Modules?
- What Repository Layout Works Best for Terraform Modules?
- How Do You Test and Enforce Terraform Module Standards?
- Terraform Module Checklist for Authors and Reviewers
- An Editor’s Take on Module Design Trade-Offs
- Sources
What Is the Standard Terraform Module Structure?
Every Terraform module you write is, at minimum, a directory that Terraform can point a source argument at. HashiCorp’s standard module structure defines what that directory needs to contain to work well with tooling, the registry, and other engineers who didn’t write it.
The required part is smaller than most people expect. Terraform itself only cares that your root module has .tf files it can parse. Everything else in the standard layout exists for humans and for registry interfaces, not for the Terraform binary.
A minimal, complete module looks like this:
main.tf: the entry point, where resources and module calls live.variables.tf: every input the module accepts.outputs.tf: every value the module exposes to callers.README.md: what the module does, how to call it, and why it exists.
That’s the floor. A more complete module, especially one you plan to publish or share across teams, typically adds LICENSE, examples/, and a modules/ subdirectory for nested submodules, following the same pattern HashiCorp lays out for developing modules.
The modules/ subdirectory carries a specific meaning worth internalizing. Anything you put there is treated as a nested module, callable from your root module or, if it has its own README.md, callable externally by other configurations entirely. Skip the README on a nested module and you’re signaling “internal use only,” even if nothing technically stops someone from calling it anyway. That distinction matters once your repo grows past a handful of contributors, because it tells reviewers exactly which submodules are safe to depend on outside their original context.
What Should Go in Each File?
Knowing the file names is the easy part. Knowing what belongs in each one is where most module authors trip up, usually by cramming too much into main.tf or exposing every internal knob as a variable “just in case.”
main.tfis your entry point, not a dumping ground. Keep resource blocks and nestedmodulecalls readable at a glance. Ifmain.tfscrolls past a couple hundred lines, that’s usually a sign the module is trying to do too much, not that you need better folding in your editor.variables.tfshould expose only what callers genuinely need to change. Every variable needs adescriptionand atype; give sensibledefaultvalues where one exists, but don’t default something that changes behavior in a way a caller should consciously decide.outputs.tfexports what downstream configurations actually consume, like resource IDs, ARNs, or connection strings, each with a short description explaining what the value is and when you’d use it.examples/should contain at least one runnable, minimal example of the module in real use, not a contrived showcase of every optional argument.- Add a
terraform.tfvars.exampleso new users have something to copy instead of reverse-engineeringvariables.tf, and a.gitignorethat excludes.terraform/, state files, and any*.tfvarsthat might carry secrets.
One rule that trips up a surprising number of experienced engineers: don’t put provider blocks inside a module. Modules inherit providers from whatever configuration calls them, and hardcoding one inside a module breaks that inheritance the moment someone needs to run the same module against a second region or account. Terraform’s own module tutorials are explicit about this, and it’s worth reading if you’ve ever wondered why your “reusable” module only works in one place.
Pro Tip: Write your examples/ directory before you write your README. Documenting a module by describing an example that actually runs forces you to notice awkward variable names or missing outputs before anyone else does.
How Narrow Should a Terraform Module Be?
This is the design question that determines whether your module survives contact with a second team. HashiCorp’s own guidance on module creation is blunt about it: aim for an MVP that satisfies about 80% of use cases, and resist the urge to handle every edge case a hypothetical future user might have.
If you find yourself adding a fifth conditional branch to handle a use case that’s happened once, you’re probably building for an edge case instead of the common path. A narrow, opinionated module is easier to test, easier to document, and easier for someone else to trust without reading every line.
A few patterns to watch for:
- Separate long-lived resources from volatile ones. A module managing a VPC that never changes shouldn’t also own an autoscaling group that gets resized weekly. Different lifecycles deserve different modules.
- Enforce privilege boundaries at the module level. If a module provisions IAM policies alongside application infrastructure, you’ve quietly coupled two things that should have separate review paths.
- Avoid thin wrappers. If your module is a single resource block with a couple of variables bolted on, you’ve added an abstraction layer with no real benefit. Just use the resource directly in the calling configuration.
- Watch for conditional sprawl. A module riddled with
count = var.enable_x ? 1 : 0for a dozen optional features is usually two or three modules wearing a trench coat.
If you’re deciding between a dedicated module and a Terraform workspace for handling environment variation, it’s worth reading through the trade-offs between workspaces and modules before committing to either.
How Deep Should Module Nesting Go?
Terraform doesn’t stop you from nesting modules five levels deep, but that doesn’t mean you should. HashiCorp’s own developer guidance leans toward a relatively flat module tree with composition happening at the root, and production teams that ignore this usually end up debugging plan output that reads like a stack trace.
A workable rule of thumb: keep primary module nesting to about two levels. A root module calls a module, which may call one more nested module inside modules/, and that’s typically where it should stop. Beyond that, you’re better off composing multiple sibling modules at the root and wiring their outputs together explicitly than burying a third layer where nobody can trace a variable back to its source.
- Use
modules/for submodules that are tightly coupled to the parent and not meant for independent reuse. - Publish anything meant for reuse across projects to a registry or a separate VCS repository instead of nesting it deeper inside an existing module.
- Standardize input and output names across related modules (
vpc_id,subnet_ids,security_group_id) so wiring one module’s outputs into another’s inputs doesn’t require a lookup table every time. - When one module’s output feeds directly into another’s input, keep that data flow visible at the root level rather than hidden two modules deep.
Consistent naming does more work than most teams give it credit for. When every VPC-related module calls its output vpc_id instead of a different name each time, composing modules at the root becomes a matter of pattern matching rather than reading source code. If you’re regularly passing data between separate configurations rather than just between modules in one root, the patterns in sharing data between Terraform configurations apply the same logic one level up.
How Do You Version and Pin Terraform Modules?
Module versioning is where “it worked in dev” turns into a 2 AM incident if you’re not disciplined about it. Terraform modules should follow Semantic Versioning: bump the major version for any breaking change to inputs or outputs, minor for backward-compatible additions, and patch for fixes that don’t change the interface at all.
Module addresses tell Terraform where to fetch a module from, and the format changes depending on the source:
- Registry modules use a
namespace/name/provideraddress, and Terraform’s registry protocol handles listing available versions and downloading the matching archive. - VCS modules point directly at a Git repository URL, often with a
refpinning a specific tag or commit. - Local modules use a relative path (
./modules/network) and have no version at all, since they live in the same repo.
The pinning strategy that holds up in production: pin exact versions (version = "5.0.0") in production configurations, and allow version ranges only in development environments where you’re actively testing upgrades. A Dev lays out this pattern well, including running a release pipeline that tags versions and runs integration tests against staging before anything ships to a production consumer.
Registries also aren’t a single choice. You can publish to the public Terraform Registry for open sharing, run a private registry for internal-only modules, or simply reference a VCS tag directly if you don’t need registry indexing at all. Each option trades discoverability for control, and most mid-size teams land on a private registry once they have more than two or three teams consuming the same modules.
What Repository Layout Works Best for Terraform Modules?
There are really two dominant patterns here, and which one fits depends on whether your modules are consumed only inside your own environments or shared more broadly.
The single-repo pattern keeps modules and their consuming environments in one place:
repo/
├── modules/
│ ├── network/
│ └── compute/
├── envs/
│ ├── staging/
│ └── production/
└── examples/
The multi-repo pattern gives each published module its own repository, complete with its own CI pipeline, versioning, and release tags, and consuming projects reference it by VCS or registry address instead of a local path.
| Pattern | Best for | Main trade-off |
|---|---|---|
| Single-repo | Small teams, internal-only modules, fast iteration | Harder to version modules independently; changes ripple across environments in one PR |
| Multi-repo | Modules shared across multiple teams or projects | More CI/release overhead per module; requires discipline around pinning |
Teams migrating existing, hand-managed infrastructure into either layout usually hit the same wall: deciding which resources become which module’s responsibility. That’s a separate problem from picking a repo pattern, and it’s worth reading through before you start reorganizing anything, especially if you’re importing existing infrastructure into Terraform rather than starting from a blank repo. If your environments share a lot of near-identical configuration across envs/, tools built around Terragrunt can reduce duplication without requiring a full multi-repo split, as covered in keeping Terraform DRY without the magic.
How Do You Test and Enforce Terraform Module Standards?
Structure only holds up if something enforces it besides good intentions. HashiCorp’s style guidance recommends treating formatting and validation as automated gates, not code review suggestions, and mature teams back that with linting and documentation checks on every pull request.
- Run
terraform fmt -checkandterraform validateon every PR to catch formatting drift and basic syntax errors before a human reviewer even opens the diff. - Add
tflintfor provider-specific rule checks thatvalidatedoesn’t catch, like deprecated arguments or unused variables. - Run
terraform-docsto regenerate the README’s variable and output tables automatically, so documentation can’t silently go stale. - Gate the PR on lightweight structural checks: no empty README, at least one file in
examples/, and no module merged without a version tag if it’s meant for external consumption. - Wire module publishing and version tagging into the same CI pipeline so a merged, tagged release is what other teams actually consume, not an untagged branch.
Pro Tip: Fail the build on a missing README.md the same way you’d fail it on a syntax error. Teams that treat documentation as optional in CI end up with modules nobody trusts enough to reuse, which defeats the entire point of writing modules.
If you’re building this out for the first time, the testing progression from terraform validate through native test blocks is covered in more depth in testing Terraform from validate to native tests, and keeping generated docs in sync with actual variables is easier with the workflow in generating Terraform documentation with AI and terraform-docs.
Terraform Module Checklist for Authors and Reviewers
Run through this before merging any new module or reviewing someone else’s:
main.tf,variables.tf,outputs.tf, andREADME.mdall present and non-empty.- Every variable has a
descriptionand explicittype; defaults only where they’re genuinely safe. - Every output has a
descriptionexplaining what it is and when a caller would use it. - No
providerblocks inside the module itself. - Production callers pin an exact version; nothing floats on a version range in a live environment.
- At least one working example exists in
examples/. terraform fmt,validate,tflint, andterraform-docsall run in CI, and the PR fails without them.
| Check | Why it matters |
|---|---|
| Standard file layout present | Registry tools and new contributors both expect it |
| Typed, described variables/outputs | Prevents silent misuse and undocumented behavior |
| No provider blocks in module | Preserves provider inheritance from the caller |
| Exact version pinned in production | Stops an unreviewed breaking change from shipping |
| CI enforces fmt/lint/docs | Turns structure from a suggestion into a policy |
An Editor’s Take on Module Design Trade-Offs
The biggest mistake I see in Terraform repos isn’t a missing README or an unpinned version. It’s engineers building the “complete” module on day one, wiring in every conditional they can imagine needing, before anyone has actually called it twice. Start smaller than feels comfortable. A module that does one thing well and gets extended later beats a module that tries to anticipate every future requirement and ends up unreadable by month three.
Pin exact versions in production without exception, and treat that rule the same way you’d treat a security policy, not a style preference. Include a real example directory even for internal-only modules; the five minutes it takes to write one saves hours of Slack messages later. Let CI enforce the boring stuff, formatting, docs, structure, so code review can focus on whether the module’s design actually makes sense.
Refactor when a module’s conditionals start outnumbering its resources, not before. Plenty of teams over-abstract early and end up maintaining more module code than the infrastructure it manages. If you want more patterns like this, along with prompt-driven workflows for scaffolding and reviewing modules faster, Devopsaitoolkit has deeper guides built specifically for engineers running Terraform in production, not in a tutorial sandbox.
— James
Recommended
- Keeping Terraform DRY With Terragrunt Without the Magic
- Testing Terraform: From Validate to Native Tests
- GitLab CI + Terraform: A Safe, Reviewable Infrastructure
- Surviving Terraform Provider Version Upgrades
Get 500 Battle-Tested DevOps AI Prompts — Free
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.