Terraform Guide

How to read a Terraform module structure: what the directory layout is telling you

By the engineer who builds Datadef, from client work on data platforms · Reviewed August 21, 2026

Opening an unfamiliar Terraform repository, the first question is not what does aws_iam_role.this do. It is which of these forty directories is a thing that gets applied, and which are just parts. Terraform answers that structurally, and you can recover the whole shape without running anything, because the rules are mechanical.

7 min readFor anyone opening a Terraform repository they did not write

See it as a diagram

Everything below, as a diagram you can edit. Describe yours and see it in seconds.

156/20003 credits left
Try:

No account needed · Editable canvas, not a picture

The one rule that makes the layout legible

A directory of .tf files is a module unit. A module unit that no other unit references as a module source is a root module, which people usually call a stack: it is a thing you can run terraform apply against, and it owns a state. Everything else is a part, called from somewhere.

That single rule sorts the repository. Directories under modules/ referenced with a relative source like ./modules/network are local modules, expandable from the repo itself. Calls with a registry address such as terraform-aws-modules/vpc/aws or a git source are external: their contents are not in your repo, so the call itself is the unit of meaning.

Backend blocks confirm the reading. Each backend declaration marks a separate state, and a repository with four backend blocks is four deployment units regardless of how the folders are named.

The convention is written down, which is why it holds across repositories you did not write. HashiCorp's Standard Module Structure puts nested modules under a modules/ subdirectory and examples of using the module under examples/ at the root, and asks that nested modules be composable by the caller rather than calling each other into a deeply nested tree.

A first pass in five commands

Before opening any file, get the census. These commands answer where the stacks are, what they call, where the environments live, and which providers are in play, which is roughly eighty percent of orientation.

Read outputs next. A module's outputs are its public surface: a module with rich outputs is meant to be composed with others, and a module with none is a leaf that only has side effects. When module B takes module.a.storage_account_id as an input, that is an architectural statement, not a coincidence.

# stacks: every directory that declares a backend
grep -rl 'backend "' --include='*.tf' . | xargs -n1 dirname | sort -u

# module calls and their sources
grep -rn -A3 '^module "' --include='*.tf' . | grep -E 'module "|source *=|version *='

# environments, when they live in tfvars
find . -name '*.tfvars*' | sort

# the public surface of each module
grep -rn '^output "' --include='*.tf' . | sort

# providers actually used
grep -rhoE '^resource "[a-z0-9]+' --include='*.tf' . | sort | uniq -c | sort -rn

What a parser sees that grep cannot

Grep gives you the census; it does not give you the graph. Values in Terraform are wired rather than written: a resource attribute points at var.name, which was bound at the call site to a local, which concatenates a prefix from a tfvars file. Following that chain by hand across a real repo takes an afternoon.

Datadef parses every .tf and .tfvars file and follows the wiring instead of evaluating it: variables resolved through call sites, locals, and per-environment tfvars to literal values where they exist, references resolved through module outputs to the declared resources they actually reach, count and for_each expressions turned into real multiplicity when the source is a literal collection.

The result is a diagram of the modules as zones rather than a directory listing.

The five rules that sort the directories

Datadef applies the reading above as a fixed sequence, which is worth knowing because it tells you what your directory names are doing to the output.

Rule three is the one that surprises people. A platform repository that ships usage examples keeps its real stacks and drops the examples. A module library, where the examples are the only root modules, gets inverted by rule four: the module they demonstrate becomes the subject, and it earns a deeper budget of sixteen drawn nodes instead of the eight a module gets when it is one component among many.

Rule five is why an environment name never has to be typed anywhere. It is read from the path, so envs/dev/terraform.tfvars and platform/environments/prod/main.tfvars produce DEV and PROD without configuration.

1  a directory holding .tf files is a module unit
2  a unit that no other unit names as a module source is a root module
3  a path segment matching examples, tests, fixtures, test-fixtures,
   e2e, wrappers or ci is set aside when real stacks exist elsewhere
4  if every stack is an example, the most resource-heavy referenced
   module becomes the subject instead
5  a path segment env, envs, environment or environments names the
   environment of the tfvars below it, upper-cased: envs/dev -> DEV

FAQ

How do I tell a root module from a reusable module?

A root module is a directory of .tf files that no other directory references as a module source, and it usually declares a backend. Reusable modules are always referenced by a source argument somewhere. That structural rule is reliable regardless of how the folders are named.

What does an examples/ directory mean in a Terraform repository?

It signals a module-library repo: the real deliverable is the module at the root, and the examples exist to show usage and run tests. Treating those example stacks as the architecture produces a diagram of demo material rather than of the module itself.

Where do the environments live in a Terraform repo?

Usually in one of three places: a directory per environment such as envs/dev and envs/prod, a set of tfvars files like dev.tfvars and prod.tfvars, or Terraform workspaces selected at apply time. The tfvars layout is the easiest to read statically because the values sit in the repository.

What does a module with no outputs tell me?

That it is a leaf. Nothing downstream can depend on what it creates through references, so it is either self-contained or it is coupled to the rest by naming conventions and implicit assumptions, which is worth checking before changing it.

How deep should module nesting go?

HashiCorp's Standard Module Structure asks for composition rather than depth: nested modules should ideally be composable by the caller instead of calling each other and creating a deeply nested tree. Depth also costs legibility, since a value can pass through several call sites before it reaches the resource that uses it. Datadef expands module calls six levels deep and stops there.