RizTech Academy logo
RizTech Academy
Infrastructure as CodeLesson 4 of 435 min

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.tf is mostly module calls 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 fmt keeps the code consistently formatted; terraform validate catches 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

  1. Explain how copying Terraform config per environment reintroduces the drift IaC was meant to remove.
  2. Write a module block that calls a web-service module with different replicas for staging vs production.
  3. Sketch a repository layout separating modules/ from environments/, and explain what each holds.
  4. Explain why each environment should have its own state, and what isolating state protects against.
  5. Explain how variables should be the only difference between environments, and why that prevents drift.
  6. List three habits that keep a Terraform codebase maintainable, and when you would introduce modules.

Official documentation

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