Introduction Open a Terraform repository you have never seen before.
There is a good chance it looks like this: one with eight hundred lines in it, a where required and optional inputs are shuffled together in alphabetical order, a provider block with no version constraint at all, and a that documented the inputs accurately about four months ago.
None of that is anybody's fault.
Terraform doesn't care how you arrange your files.
Every file ending in in a directory gets concatenated before anything is evaluated, so the language gives you no reason to prefer one layout over another.
The tutorials all put everything in because they're teaching one resource at a time, and then that becomes the shape of the repo forever.
I'm not a developer by trade.
I don't have the muscle memory that lets someone navigate an unfamiliar codebase by feel.
What I have instead is a layout I use every single time, so that six months from now, when I come back to a module I half remember, I know where things are before I open anything.
This post is that layout, and the reasoning behind each piece of it.
Every convention here is a response to a specific way I have watched a module go bad.
It's all based on the HashiCorp style guide, with opinions layered on where the style guide leaves room.
Everything below is scaffolded in terraform-module-template, which I use for modules and root modules alike.
TL;DR , , , , , , and always exist, even when empty.
Resources live in until there are roughly twenty of them, then split by service or function. is two sections, required before optional, alphabetized inside each. is one alphabetized list. holds and nothing else. belongs in .
Pin everything pinnable with .
Generate the README with , do not write it.
Run , , , , and in pre-commit, so you find problems before a plan does.
Problem 1: Everything Ends Up in main.tf A module starts with three resources, so of course they go in .
Then it grows.
Nobody ever decides to put eight hundred lines in one file.
It happens because the alternative requires a decision, and there's never a good moment to make it.
The Fix: Files by Role First, Size Second Some file names are reserved for a role, and those files always exist: Resources stay in until there are around twenty of them.
Past that, splits by service or function: , , .
The number isn't sacred.
The point is that the split is triggered by size, and only resources ever get split.
Data sources, locals, and provider configuration stay in their own files no matter how few or how many there are.
That asymmetry is deliberate.
Resource count varies enormously between modules, so where resources live has to scale.
The other categories are small and bounded, and their value comes from being findable at a fixed address rather than from being organized well.
Why Empty Files Stay In a fresh module, and are empty except for a header comment: This is the convention people push back on most, and I'll keep defending it.
An absent file carries no information.
You can't tell whether this module has no data sources or whether someone put them in without looking.
A present, empty file answers both questions at once: there are no data sources yet, and here is where the next one goes.
Most structural decay in a repo doesn't come from someone disagreeing with the layout.
It comes from someone adding a data source at 5pm, not seeing an obvious home for it, and dropping it at the bottom of .
An empty removes the decision, which is the only reliable way to make a convention survive contact with a deadline.
Problem 2: You Can't Tell Required From Optional Alphabetical ordering in sounds obviously correct, and it's the default advice.
It also means that the first thing you want to know about a module - what do I have to give it? - is the one thing the file won't tell you.
You have to read every block and check each one for a .
The Fix: Two Sections, Alphabetized Inside Each is split into required variables, meaning no , followed by optional variabl