Skip to content
DevOps AI ToolKit
All guides
AI for Automation By James Joyner IV · · 13 min read

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

MVP Terraform Module Structure for Production Teams: CI and SemVer

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?

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

  1. main.tf is your entry point, not a dumping ground. Keep resource blocks and nested module calls readable at a glance. If main.tf scrolls 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.
  2. variables.tf should expose only what callers genuinely need to change. Every variable needs a description and a type; give sensible default values where one exists, but don’t default something that changes behavior in a way a caller should consciously decide.
  3. outputs.tf exports 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.
  4. examples/ should contain at least one runnable, minimal example of the module in real use, not a contrived showcase of every optional argument.
  5. Add a terraform.tfvars.example so new users have something to copy instead of reverse-engineering variables.tf, and a .gitignore that excludes .terraform/, state files, and any *.tfvars that 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 : 0 for 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/provider address, 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 ref pinning 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.

PatternBest forMain trade-off
Single-repoSmall teams, internal-only modules, fast iterationHarder to version modules independently; changes ripple across environments in one PR
Multi-repoModules shared across multiple teams or projectsMore 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.

  1. Run terraform fmt -check and terraform validate on every PR to catch formatting drift and basic syntax errors before a human reviewer even opens the diff.
  2. Add tflint for provider-specific rule checks that validate doesn’t catch, like deprecated arguments or unused variables.
  3. Run terraform-docs to regenerate the README’s variable and output tables automatically, so documentation can’t silently go stale.
  4. 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.
  5. 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, and README.md all present and non-empty.
  • Every variable has a description and explicit type; defaults only where they’re genuinely safe.
  • Every output has a description explaining what it is and when a caller would use it.
  • No provider blocks 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, and terraform-docs all run in CI, and the PR fails without them.
CheckWhy it matters
Standard file layout presentRegistry tools and new contributors both expect it
Typed, described variables/outputsPrevents silent misuse and undocumented behavior
No provider blocks in modulePreserves provider inheritance from the caller
Exact version pinned in productionStops an unreviewed breaking change from shipping
CI enforces fmt/lint/docsTurns 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

Newsletter

Free: the DevOps AI Incident-Triage Cheat Sheet

Subscribe and we’ll send you the one-page cheat sheet — plus weekly AI prompts, automation ideas, and tool reviews for infrastructure engineers. One email a week. No spam, unsubscribe anytime.

  • AI Incident-Triage Cheat Sheet (PDF)
  • Access to 2,778 DevOps AI prompts
  • One practical workflow email per week
Free download · 368-page PDF

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.