Modules and structuring a repository
A few resources in one main.tf is fine to learn with. A real project has many resources across several
environments, and if you copy-paste that configuration for staging and production, you get the very drift and
duplication IaC was meant to eliminate. Modules and a sensible repository structure are how Terraform
scales to a real project without becoming a mess. This lesson is organising Terraform, and it closes the IaC
module.
The problem: duplication and drift, again
Suppose you have staging and production, each needing the same set of resources — a network, servers, a
database. The naive approach copies the configuration into staging/ and production/ folders. Now the same
mistake as ClickOps returns in a new form: the two copies drift (someone fixes a bug in one and forgets the
other), and every change must be made twice. Duplication is as much the enemy in infrastructure code as in
application code (the DRY principle from the practices lessons), and the fix is the same: extract the repeated
thing into one reusable unit.
Modules: reusable units of infrastructure
A Terraform module is a reusable, parameterised group of resources — the infrastructure equivalent of a function. You define a module once (a set of resources, with input variables and outputs), then call it wherever you need that infrastructure, passing different inputs each time.
Any directory of .tf files is a module. You call one with a module block:
module "web_service" {
source = "./modules/web-service" # where the module's code lives
name = "greetings"
replicas = 3 # inputs — different per environment
instance_size = "small"
}
The module (./modules/web-service) contains the resources — the server, the load balancer, the security group
— written once, with variables (name, replicas, instance_size) as its inputs. Now staging and
production both call the same module with different inputs (production with more replicas, a bigger size),
instead of copying the resources. Fix or improve the module once, and every environment that calls it benefits.
This is exactly the reuse that page objects and functions give in code, applied to infrastructure.
Modules also come from registries — the Terraform Registry has community and official modules for common infrastructure (a VPC, an EKS cluster), so you often call a well-tested module rather than writing the resources yourself. Using a proven registry module for something like AWS networking is usually wiser than hand-writing dozens of resources.
Structuring a repository
A common, sensible layout separates reusable modules from per-environment configuration:
terraform/
modules/ # reusable building blocks (called, not applied directly)
web-service/
main.tf
variables.tf
outputs.tf
network/
environments/ # one per environment — each calls the modules
staging/
main.tf # module "web_service" { ... replicas = 1 }
terraform.tfvars
production/
main.tf # module "web_service" { ... replicas = 3 }
terraform.tfvars
The principles behind this:
- Modules are reusable; environments are thin. A module holds the how (the resources); an environment
holds the what for this environment (which modules, with which inputs). An environment's
main.tfis mostlymodulecalls with different variables — so environments stay small and consistent. - Each environment has its own state. Staging and production keep separate state files (separate remote backends or workspaces), so a change to staging cannot touch production. Isolating state per environment is an important safety boundary.
- Variables carry the differences. The only differences between environments should be inputs (sizes, counts, names) — everything structural comes from the shared modules, so the environments cannot drift in shape, only in the parameters you intend.
This structure gives you the goal of IaC: identical environments by construction, differing only where you mean them to, with no duplication to drift.
Habits for maintainable Terraform
A few practices that keep an infrastructure codebase healthy (echoing the code-quality lessons):
- Keep modules focused and well-named — one module, one clear responsibility (a network, a service), with documented inputs and outputs. A giant do-everything module is as bad as a giant function.
- Pin module and provider versions — so an upstream change does not alter your infrastructure unexpectedly (the reproducibility discipline again).
- Format and validate —
terraform fmtkeeps the code consistently formatted;terraform validatecatches errors before you plan. Run both in CI. - Review the plan in PRs — infrastructure changes go through pull requests with the plan attached (the previous lesson), reviewed like any code.
- Don't over-engineer early. For a small project, a single configuration is fine; introduce modules when you have real duplication (a second environment), not before — premature abstraction is a cost, exactly as with application code.
The through-line of this whole module: infrastructure is code, so treat it like code — version it, review it, keep it DRY with modules, structure it sensibly, and never apply without reading the plan. Do that, and your infrastructure becomes as reliable, reviewable and repeatable as your application — which is the entire point of Infrastructure as Code, and a core part of the DevOps job.
Check your work
The problem: copying config per environment (staging/, production/) reintroduces drift and duplication —
the DRY problem in infrastructure. Fix: extract the repeated thing into one reusable unit.
Modules = reusable, parameterised groups of resources (infrastructure's "functions"): define once with
input variables + outputs, module "x" { source = ... } to call with different inputs per environment. Fix
once, all callers benefit. Registry modules (VPC, EKS) let you call proven ones instead of hand-writing
resources.
Structure: modules/ (reusable how) + environments/{staging,production}/ (thin what-for-here — mostly
module calls with different vars). Each environment has its own state (safety boundary); variables carry
the only differences — so environments can't drift in shape.
Habits: focused/well-named modules with documented I/O; pin module+provider versions; terraform fmt +
validate in CI; review the plan in PRs; don't introduce modules before there's real duplication (premature
abstraction is a cost). Through-line: infrastructure is code — version, review, keep DRY, read the plan.
Practice
- Explain how copying Terraform config per environment reintroduces the drift IaC was meant to remove.
- Write a
moduleblock that calls aweb-servicemodule with differentreplicasfor staging vs production. - Sketch a repository layout separating
modules/fromenvironments/, and explain what each holds. - Explain why each environment should have its own state, and what isolating state protects against.
- Explain how variables should be the only difference between environments, and why that prevents drift.
- List three habits that keep a Terraform codebase maintainable, and when you would introduce modules.
Official documentation
- Terraform — Modules — Defining and calling reusable modules.
- Terraform — Module creation & standard structure — How to structure a module and a repository.
- Terraform Registry — Community and official modules to reuse.
Next: the Observability module.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship