Modules, Composition & Versioning
A Terraform module is a versioned interface, not a dump of the whole account. This page covers composition, pins, registries, and when a root should stay flat.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
When is a folder a module instead of just a root?
Answer
When more than one caller needs the same contract, or you need a versioned release. A one-off root can stay flat.
L2
What belongs in outputs?
Answer
IDs and ARNs callers need. Not every internal resource.
L3
Where are provider blocks configured?
Answer
In the root. Child modules receive aliases. They do not declare their own provider.
L4
What pin is safe for prod?
Answer
A pessimistic minor constraint or an exact version or git SHA. A branch named main is not a pin.
L5
What forces a major module version?
Answer
Removing or renaming a variable or output, or a force-new change inside that callers cannot absorb.
L6
How do you move a resource into a module without recreating it?
Answer
terraform state mv from the root address to the module address, then a plan with no destroy.
L7
Monorepo modules or one repo per module?
Answer
Monorepo while one platform team consumes them. Separate repos when many external callers need a release boundary.
Failure modes
ref=main in a prod root
The module moves under you. The plan you reviewed is not the code apply runs next week.
Eighty-variable mega-module
The module is a root in disguise. Callers cannot see which input causes a replace.
Secret strings as variables
The value lands in state and in the plan log. Pass a reference to the secret store instead.
enable_x implemented with count
Flipping the flag destroys and recreates the object instead of toggling a leaf.
Silent ignore_changes inside the module
Callers cannot see the blast radius the module hid from the plan.
Misconceptions
Every root should be modularized on day one.
A module pays off when it is reused or versioned. Premature modules add indirection.
A local path is a version.
A local path moves with the branch. Prod callers need a tag, a registry version, or a SHA.
Terragrunt is required to be senior.
It is an optional DRY wrapper. You still have to explain backends and provider configuration.
Interviewer traps
Putting provider blocks and access keys inside the shared module.
Providers stay in the root. Machine credentials, if unavoidable, go to a secret store and stay out of outputs.
Redesigning CIDR math when the question was the module interface.
Name the private-networking page for VPC semantics. Stay on inputs, outputs, and pins.
Design scenario
Same prompt for every reader.
Requirements
One job per module. Providers configured only in roots. Prod pins an exact version or SHA. Changelogs note force-new changes. Major bumps require an approval label.
Traffic / scale
Platform releases a module a few times a month. App roots bump non-prod first.
Latency
A version bump shows up as a reviewable plan, not as a surprise on the apply job.
Consistency
After a state mv into a module, the plan has no destroy. A minor bump does not rename outputs.
Availability
Non-prod absorbs the new module version before prod. A bad major can stay unpinned until the label and the plan agree.
Failure assumptions
- main moves between plan and a later unpinned apply.
- An output rename is released as a patch.
- A secret is passed as a plain variable.
Constraints
- No provider blocks in child modules.
- No secret values in inputs or outputs.
- Prod does not use a branch as a version.
Prompt
Three accounts copy a VPC and an RDS module by hand. Prod tracks a git branch named main. A variable rename shipped on that branch and the next apply wants to replace the database.
API
What does the module block pin, and what does the root pass in?
Data
Which outputs are stable across a minor release?
Architecture
Where do the registry version, the root provider alias, and the prod state root sit?
Four accounts need the same VPC shape and prod cannot float
Prefer
A thin root and a pinned child module
The module publishes subnets and route tables. Each account root pins a version and keeps its own state.
- A minor bump is planned in non-prod before prod.
- Outputs are IDs, so callers do not bind to private resources.
- The changelog names any force-new change inside the module.
Alternative
An everything-module on main
One module creates the network, the database, the IAM users, and the app, and prod tracks the branch.
- A variable rename replaces objects the caller did not mean to touch.
- Provider aliases inside the module fight the root.
- There is no version to roll back to.
Publish a module callers can upgrade
Composition is still a resource graph once Terraform flattens it. The version is the contract.
- 1
One job and a small interface
Typed variables, validations, and outputs limited to IDs and ARNs. - 2
Providers stay in the root
Child modules receive aliases. They do not embed credentials or provider blocks. - 3
Pin and changelog
Prod uses a minor constraint or an exact SHA. Breaking outputs and force-new behavior ship as a major. - 4
Promote through environments
Bump non-prod, read the replace plan, then bump prod. A major without an approval label waits.
Overview
Modules are Terraform's reuse boundary. A good module exposes a small typed interface and pins cleanly. A bad module hides blast radius inside eighty variables and a branch named main.
What a good module looks like
- One job. "VPC with public and private subnets" is a module. "The entire company account" is a root.
- Narrow variables. Typed, validated, and defaulted only when the default is safe.
- Narrow outputs. The IDs and ARNs callers need.
- No provider blocks in shared children. Roots configure providers and pass aliases.
- Examples and a changelog. Callers need migration notes when an output is renamed.
- Tests.
terraform test, Terratest, or at least a plan fixture in CI.
module "net" {
source = "app.terraform.io/acme/vpc/aws"
version = "~> 3.2"
name = "payments"
cidr = "10.16.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
enable_nat_gateway = true
tags = var.tags
}CIDR layout, NAT, and ingress behavior live on private networking. The module should not invent a second addressing scheme. Secret values are references, as on Secrets and KMS. A plain string variable that lands in state is an anti-pattern.
Composition patterns
| Pattern | When it fits | How it fails |
|---|---|---|
| Thin root plus many children | Each domain has an owner | Interfaces churn between modules |
| Opinionated stack module | A golden path for app teams | It becomes a mega-module |
| A wrapper such as Terragrunt | You want DRY backends and providers | A second DSL to debug |
| Copy-pasted roots | A tiny prototype | Environments drift |
The senior default is a thin root per account slice and versioned children for repeated shapes: VPC, database, IAM role patterns.
Flow
- 1
1. Prod payments root
- next2. Pinned vpc module
- next3. Data and IAM
- 2
2. Pinned vpc module
- next4. RDS uses vpc outputs
- 3
3. Data and IAM
- next5. Narrow remote outputs
- 4
4. RDS uses vpc outputs
- 5
5. Narrow remote outputs
Lesson map
Modules, Composition & Versioning
A Terraform module is a versioned interface, not a dump of the whole account. This page covers composition, pins, registries, and when a root should stay flat.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB root["1. Prod payments root"] vpc["2. Pinned vpc module"] data["3. Data and IAM"] rds["4. RDS uses vpc outputs"] root -->|1. Prod payments root| vpc root -->|1. Prod payments root| data vpc -->|2. Pinned vpc module| rds
Edges between modules are resource edges once the graph is flattened. A version bump of the database module should not replace the VPC.
Version rules that survive prod
- Semver for modules you publish. A breaking variable or output change is a major.
- Roots pin a minor range or an exact version on critical paths.
- Prod does not use a branch ref.
ref=mainis not a version. - The changelog names force-new changes inside the module, because callers see them as replaces.
- Promote through environments. Bump non-prod, read the plan, then bump prod.
Decisions
- ?
1. Major version bump?
- no2. Plan in non-prod
- yes3. Approval label set?
- 2
2. Plan in non-prod
- ?
3. Approval label set?
- yes2. Plan in non-prod
- no4. Block the bump
- 4
4. Block the bump
Where the source lives
| Source | Gain | Cost |
|---|---|---|
| Public or private registry | Version constraints and discovery | Registry uptime and a publish pipeline |
| Git tag or SHA | Review in the same org | Easy to float a branch. Weaker discovery |
| Local path | Fast iteration in a monorepo | No version. Easy cross-environment coupling |
| OCI or S3 mirror | Works in an air-gapped network | Extra plumbing |
Anti-patterns
- Secret variables that land in state. Pass a name or ARN.
counttoggles that destroy a critical object whenenable_xflips.- Provider configuration inside the child fighting the root alias.
- One module with dozens of variables. That is a root wearing a module block.
ignore_changesburied where callers cannot see it.
Moving an address in
terraform state mv from aws_vpc.main to module.net.aws_vpc.main, then a plan that shows the move and no destroy. Practice in non-prod. The graph page owns the general state mv rule. Here the address prefix is the module.
Monorepo versus a repo per module
| Modules beside the apps | A repository per module |
|---|---|
| Atomic refactors with callers | A clear version boundary |
| Risk of always living on main | Release overhead |
| Fits a small platform team | Fits many external consumers |
Pick from consumer count and release discipline.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Wire the real gate to terraform plan -json and the module manifest. The predicate is the idea.
Private registry
| Gain | Cost |
|---|---|
| Version UX and a blessed catalog | You operate the registry |
| App teams can discover modules | Publish latency versus a git tag |
| A place to attach policy | The registry can become a bottleneck |
Interface checklist before you tag
Interview Q&A
Module versus a root folder?
Answer
Make a module when the shape is reused or you need a versioned contract. A one-off environment root can stay flat. Premature modules add indirection without a second caller.
How do you move a resource into a module without recreating it?
Answer
terraform state mv from the root address to the module address. The next plan should show no destroy and no create. Rehearse in non-prod.
Should a module create IAM users and access keys?
Answer
Almost never for humans. Prefer roles and OIDC. If a machine credential is unavoidable, write it to a secret store and mark it sensitive. Do not print the key in an output or a CI log.
Is Terragrunt required?
Answer
No. It is an optional way to DRY backends and providers. Native Terraform with a thin root is a complete answer. You should still be able to explain where the backend and the provider are configured.
What is wrong with source ref=main?
Answer
Main moves. The code apply runs next week is not the code review saw. Prod pins a tag or a SHA.
Who configures the provider?
Answer
The root. Children receive an alias map. A provider block inside a shared module ignores the caller's region and account on purpose, which is the bug.
How do you treat a renamed output?
Answer
As a breaking change. Major version, changelog, and a migration note. Callers who pinned a minor range must not see the rename.
Local path modules in a monorepo. When do they hurt?
Answer
When prod and staging compile whatever happens to be on the branch, so there is no artifact to promote. A local path is for iteration. A release cuts a version.
Pitfalls
- Publishing a module that creates an entire account.
- Defaults that open a network path the caller did not ask for.
- Tests that only run
terraform validateand never a plan fixture. - A wrapper DSL that nobody on call can expand when the plan is wrong.
- Hiding a replace inside a minor bump.
You are publishing module.vpc. List three outputs you would expose, one input you would refuse because it is a secret, and the version change you would require if you renamed private_subnet_ids.