Drill — Write a Reusable IaC Module
Infrastructure as code told you a declarative config is a photo of the finished cake, not a recipe of steps. This drill is about a skill that page only gestures at: writing that photo so it works for more than one cake. You'll start by hardcoding a config the fast way, watch that exact shortcut produce a bug the moment someone copy-pastes it, then refactor into a properly parameterized module — typed inputs, validation, sensible defaults, zero hardcoded values — and call it three times from a single map instead of three times by hand. The last third of the drill is the part most tutorials skip: reading terraform state list closely enough to explain exactly what changed and why, including one classic trap — renaming a for_each key — that quietly plans a destroy-and-recreate of something you never meant to touch. Everything runs locally against the random and local providers. No cloud account, no cost, nothing to tear down but a folder.
A resource block with real values typed straight into it is a birthday card you hand-lettered for one specific kid — "Happy Birthday, Sam!" It's perfect, for Sam, once. A module is a rubber stamp: the shape of the card is fixed, but you press it onto a different name each time and every kid in the class gets a real, correctly-spelled card — as long as the stamp actually takes a name as an input instead of having "Sam" carved into the rubber. Today you carve a stamp that takes a name, catch yourself hand-lettering a second card instead of using it, and then build the stamp properly.
You need Terraform 1.5 or newer — check with terraform version; the validation and precondition blocks below need it. Nothing else: this drill deliberately uses only the hashicorp/random and hashicorp/local providers, which read and write nothing outside a folder on your own disk — no AWS/GCP/Azure account, no credentials, no bill. Terraform's HCL and CLI output format do shift between minors (error-message formatting especially), so treat the terminal output quoted below as "the shape of what you'll see," not a byte-exact transcript — if yours differs cosmetically, that's normal, not a sign you did something wrong.
How this drill works
☺ Like you're 10: One config, evolved in place through seven steps — not two separate puzzles, so work them in order.
Unlike some drills on this course, this one isn't two independent scenarios you can tackle in either order — it's a single config that gets rewritten under you, step by step, the same way a real module actually gets born: someone hardcodes something to ship it fast, someone else needs the same thing and copies it, the copy quietly drifts, and only then does anyone sit down and build it properly. Budget about 45 minutes end to end. Each step tells you exactly what to type and exactly what you should see back — if your terminal disagrees with the quoted output in a way that looks structural rather than cosmetic, stop and re-read the previous step before moving on; state problems compound if you keep going past one you don't understand.
Set up the scratch directory
☺ Like you're 10: One empty folder, nothing borrowed from anywhere else on the course.
Create one throwaway directory. You'll rewrite the .tf files inside it several times over the course of this drill — nothing here needs to survive past today.
mkdir iac-module-drill && cd iac-module-drill
mkdir generated
terraform version # confirm 1.5.0 or newerStep 1 — ship it the fast way
☺ Like you're 10: Type the real values straight into the block. It works, and that's exactly the trap.
The checkout team needs a small generated service-config file for their dev environment: which port to listen on, how many replicas, whether debug logging is on. Nobody's asked for this twice yet, so write the honest, fastest thing that could work — a resource block with the real values typed straight in:
# main.tf — first draft, values typed straight into the block
terraform {
required_version = ">= 1.5.0"
required_providers {
local = { source = "hashicorp/local", version = "~> 2.5" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
resource "random_id" "suffix" {
byte_length = 4
}
resource "local_file" "service_config" {
filename = "${path.module}/generated/checkout-dev-${random_id.suffix.hex}.json"
content = jsonencode({
team = "checkout"
environment = "dev"
port = 8080
replicas = 2
debug = true
})
}terraform init
terraform apply -auto-approve
cat generated/*.json
terraform state listYou should see the file land with exactly the content you typed, and state tracking exactly two resources:
{"debug":true,"environment":"dev","port":8080,"replicas":2,"team":"checkout"}
local_file.service_config
random_id.suffixDone when: the generated JSON file matches the block above field for field, and terraform state list shows exactly those two resources. This works. That's the whole problem with it — it works so cleanly that copying it feels like the obviously right move for the next request.
Step 2 — copy-paste it for a second team, and watch it lie
☺ Like you're 10: Copy the card, change the name — but this time you also forget to erase one word underneath.
The search team wants the same thing for their staging environment. The fast move — the one that feels reasonable when you're mid-sprint and this is a one-off — is to copy the two blocks, rename them so Terraform doesn't collide on identical resource addresses, and edit the values. Append this to main.tf:
# appended to main.tf — copy-pasted from the block above, edited by hand
resource "random_id" "suffix_search" {
byte_length = 4
}
resource "local_file" "service_config_search" {
filename = "${path.module}/generated/search-staging-${random_id.suffix_search.hex}.json"
content = jsonencode({
team = "search"
environment = "dev"
port = 8080
replicas = 2
debug = true
})
}terraform apply -auto-approve
terraform state list
cat generated/search-staging-*.jsonlocal_file.service_config
local_file.service_config_search
random_id.suffix
random_id.suffix_search
{"debug":true,"environment":"dev","port":8080,"replicas":2,"team":"search"}Read that generated file closely. "environment":"dev" — for the team that explicitly asked for staging. Somewhere in the copy-paste-and-edit, the environment line didn't get touched. Terraform applied it perfectly. That's not a contradiction — it's the whole lesson of this step.
Terraform's only promise is that applied state matches your configuration. It has no opinion on whether your configuration is correct — a hardcoded, copy-pasted literal that's simply wrong applies exactly as cleanly as a correct one, because from Terraform's side there is no difference between them. And notice what wouldn't have caught this even if you'd already built the validation you're about to write in Step 3: "dev" is a perfectly valid environment name. The bug here isn't an invalid value, it's a correct value typed into the wrong copy by hand. Validation catches typos. It does not catch a human copying the wrong line. Only removing the duplication does that — which is exactly what Step 3 is for.
Also look at what terraform state list just told you: four resources with four independently hand-invented names, and nothing in state saying these two pairs are "the same kind of thing, twice." If a third team shows up tomorrow, you're copying and editing by hand a third time, with a third chance to leave a stale literal in place.
Done when: you can point at the exact field in search-staging-*.json that's wrong, and explain in one sentence why terraform apply had no way to know it was wrong.
Step 3 — build the module: variables, validation, sensible defaults
☺ Like you're 10: Carve the rubber stamp — build in the blanks, not the name.
Wipe the two flat resources out of main.tf — they're about to become a module. Create modules/service-config/ and put three files in it. Nothing in these files may mention checkout, search, payments, dev, staging, or prod by name — that's the entire point of a module, and you'll prove it with a grep in the next step.
rm generated/*.json main.tf
rm -rf .terraform .terraform.lock.hcl terraform.tfstate*
mkdir -p modules/service-config# modules/service-config/variables.tf
variable "team_name" {
type = string
description = "Owning team. Becomes part of the generated file name and the \"team\" field."
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,30}$", var.team_name))
error_message = "team_name must be lowercase alphanumeric/hyphens, starting with a letter."
}
}
variable "environment" {
type = string
description = "Deployment environment for this service config."
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
variable "port" {
type = number
description = "Port the generated config tells the service to listen on."
default = 8080
validation {
condition = var.port > 0 && var.port < 65536
error_message = "port must be between 1 and 65535."
}
}
variable "replica_count" {
type = number
description = "Number of replicas recorded in the generated config."
default = 2
validation {
condition = var.replica_count >= 1
error_message = "replica_count must be at least 1."
}
}
variable "enable_debug" {
type = bool
description = "Turns on verbose/debug logging in the generated config. Off unless a caller opts in."
default = false
}
variable "tags" {
type = map(string)
description = "Freeform tags merged into the generated config."
default = {}
}
variable "output_dir" {
type = string
description = "Directory (relative to the root module) configs are written into."
default = "generated"
}Every argument that varies by caller is a variable, typed, described, and — where a wrong value is meaningful — validated. port, replica_count, enable_debug, and tags get sensible defaults, because most callers won't need to override them; team_name and environment get none, because a module silently guessing your team name is a worse failure mode than a plan that stops and asks.
# modules/service-config/main.tf
terraform {
required_providers {
local = { source = "hashicorp/local", version = "~> 2.5" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
resource "random_id" "suffix" {
byte_length = 4
}
locals {
file_name = "${var.team_name}-${var.environment}-${random_id.suffix.hex}"
}
resource "local_file" "config" {
filename = "${path.root}/${var.output_dir}/${local.file_name}.json"
content = jsonencode({
team = var.team_name
environment = var.environment
port = var.port
replicas = var.replica_count
debug = var.enable_debug
tags = var.tags
})
lifecycle {
precondition {
condition = !(var.environment == "prod" && var.enable_debug)
error_message = "enable_debug must be false when environment is \"prod\" -- no verbose logging in production configs."
}
}
}Two things worth noticing. First, path.root instead of path.module in the filename — inside a module, path.module would resolve to modules/service-config/ itself, which isn't where anyone wants generated output to land; path.root always points at the config you actually ran terraform in, regardless of how deep the module gets called from. Second, the lifecycle.precondition block: it's a guardrail that doesn't live in any single variable, because it depends on two of them together. No amount of validating environment alone or enable_debug alone catches "these two specific values together are unsafe" — that check belongs on the resource, not on either input.
# modules/service-config/outputs.tf
output "config_path" {
description = "Filesystem path to the generated config for this instance."
value = local_file.config.filename
}
output "instance_id" {
description = "The random suffix that makes this instance's filename unique."
value = random_id.suffix.hex
}
output "summary" {
description = "One-line human summary, handy for `terraform output`."
value = "${var.team_name}/${var.environment} on port ${var.port} (${var.replica_count} replicas)"
}Done when: modules/service-config/ contains exactly these three files, and every value that differs between the checkout, search, and payments use cases is a variable reference, never a literal.
Step 4 — call it three times from one map, not three times by hand
☺ Like you're 10: One list of names, one stamp, pressed once per name automatically.
Write a new root main.tf. The three teams' details live in exactly one place — a locals map — and a single module block with for_each presses the stamp once per entry:
# main.tf — the whole root module
terraform {
required_version = ">= 1.5.0"
}
locals {
services = {
"checkout-dev" = { team_name = "checkout", environment = "dev" }
"search-staging" = { team_name = "search", environment = "staging", replica_count = 4, port = 8081 }
"payments-prod" = { team_name = "payments", environment = "prod", replica_count = 6, tags = { pci = "true" } }
}
}
module "service" {
for_each = local.services
source = "./modules/service-config"
team_name = each.value.team_name
environment = each.value.environment
port = try(each.value.port, 8080)
replica_count = try(each.value.replica_count, 2)
enable_debug = try(each.value.enable_debug, false)
tags = try(each.value.tags, {})
}
output "service_configs" {
description = "Every generated config path, keyed the same way as local.services."
value = { for key, svc in module.service : key => svc.config_path }
}
output "service_summaries" {
value = { for key, svc in module.service : key => svc.summary }
}The try(each.value.port, 8080) pattern is what lets each map entry override only what it needs to — search-staging sets a custom port, the other two fall through to the module's own default. It's a workable idiom on any Terraform 1.5+; if you're on 1.3 or newer specifically and want the more self-documenting version, the "going further" box at the end of this drill shows the alternative using an optional() object type instead of try().
terraform init
terraform plan # expect: Plan: 6 to add, 0 to change, 0 to destroy
terraform apply -auto-approve
terraform output service_summaries{
"checkout-dev" = "checkout/dev on port 8080 (2 replicas)"
"payments-prod" = "payments/prod on port 8080 (6 replicas)"
"search-staging" = "search/staging on port 8081 (4 replicas)"
}Done when: terraform output service_summaries prints all three lines above, and generated/ holds three JSON files, each with the correct environment for its own team — no leftover "dev" anywhere.
Step 5 — reason about state: read the addresses
☺ Like you're 10: Every stamp press gets its own labeled slot in the filing cabinet — check the labels.
This is the step tutorials skip. Run terraform state list again, now that the module is doing the work:
terraform state listmodule.service["checkout-dev"].local_file.config
module.service["checkout-dev"].random_id.suffix
module.service["payments-prod"].local_file.config
module.service["payments-prod"].random_id.suffix
module.service["search-staging"].local_file.config
module.service["search-staging"].random_id.suffixCompare this to Step 2's flat list. Every address here is module.service["<key>"].<resource> — the map key from locals.services is the address's identity, visibly, in state itself. There is exactly one place (locals.services) where the string "staging" is written for the search team, not three. Prove the module itself stayed generic:
grep -RIn 'checkout\|search\|payments' modules/service-config/
echo "exit code: $?" # 1 = grep found nothing — the module never mentions a team by nameDone when: that grep returns nothing (exit code 1), proving zero hardcoded team or environment literals live inside modules/service-config/ — every one of them flows in from locals.services at the root.
Step 6 — let the module say no
☺ Like you're 10: A good stamp refuses to press onto a name that isn't real.
Prove the guardrails actually guard. First, add a typo'd fourth entry to locals.services:
"billing-produciton" = { team_name = "billing", environment = "produciton" }terraform planError: Invalid value for variable
on main.tf line 19, in module "service":
19: environment = each.value.environment
├────────────────
│ var.environment is "produciton"
environment must be one of: dev, staging, prod.
This was checked by the validation rule at
modules/service-config/variables.tf:13,3-13.The typo never reaches an apply — the module's own validation block stops it at plan time, with a message that names the offending value. Fix the spelling (or delete the entry) before continuing. Now try the other guardrail: set enable_debug = true on the payments-prod entry:
"payments-prod" = { team_name = "payments", environment = "prod", replica_count = 6, enable_debug = true }terraform planError: Resource precondition failed
on modules/service-config/main.tf line 29, in resource "local_file" "config":
29: condition = !(var.environment == "prod" && var.enable_debug)
├────────────────
│ var.enable_debug is true
│ var.environment is "prod"
enable_debug must be false when environment is "prod" -- no verbose logging
in production configs.Both values are individually legal — "prod" is a valid environment, true is a valid boolean — the combination is what's unsafe, and that's exactly the case a per-variable validation block can't express alone. Remove enable_debug = true from payments-prod before moving on.
Done when: you've seen both error messages fire for real, in your own terminal, and can explain out loud which block caught which mistake and why the other block couldn't have caught it.
Step 7 — the rename trap: a for_each key is the identity
☺ Like you're 10: Rename the folder label and the filing cabinet thinks the old folder vanished and a new one appeared — even though the papers inside never moved.
Cleanup pass: rename the checkout-dev key to something more consistent with the others, purely cosmetic, no values change:
"checkout-development" = { team_name = "checkout", environment = "dev" } # was "checkout-dev"terraform plan# module.service["checkout-dev"].local_file.config will be destroyed
# module.service["checkout-dev"].random_id.suffix will be destroyed
# module.service["checkout-development"].local_file.config will be created
# module.service["checkout-development"].random_id.suffix will be created
Plan: 2 to add, 0 to change, 2 to destroy.Do not apply this. Nothing about team_name or environment changed — you only renamed the map key — but for_each uses that key as the resource's identity in state, full stop. A new key looks exactly like "old instance gone, unrelated new instance appeared," which is a real destroy and a real create: a fresh random_id.suffix, a fresh file, the old one deleted. In this drill that's a harmless JSON file. In a stateful resource — a database, a disk, anything holding data you can't regenerate — this exact plan is how a cosmetic rename becomes an outage.
Fix it the way Terraform is built for: tell it explicitly that the old address and the new address are the same object, with a moved block.
# append to main.tf
moved {
from = module.service["checkout-dev"]
to = module.service["checkout-development"]
}terraform plan # now shows only the output-map key relabeling, 0 to add, 0 to destroy
terraform apply -auto-approve
terraform state list
ls generated/ # same file, same random suffix as before — nothing was touchedDone when: after the moved block, terraform plan reports zero resources to add or destroy, terraform state list shows module.service["checkout-development"] in place of the old key, and the actual generated file on disk still has the exact same random suffix it had before the rename — proof that the real resource was never recreated, only relabeled in Terraform's bookkeeping.
Two ways to push past the minimum here. First: swap the try(each.value.port, 8080) idiom for Terraform 1.3+'s optional() type constraint — give locals.services an explicit object({ team_name = string, environment = string, port = optional(number, 8080), replica_count = optional(number, 2), enable_debug = optional(bool, false), tags = optional(map(string), {}) }) type, and drop every try() call from the module block entirely; the defaults move from scattered call-site fallbacks into one declared shape, which is more self-documenting at scale. Second: add a fourth entry that deliberately sets port = 8081 on two different teams and watch nothing stop you — this module has no cross-instance validation, because Terraform's validation and precondition blocks only ever see one instance's own inputs. Real port-collision checking across a whole fleet needs either a policy tool layered on top (Sentinel, OPA/Conftest) or a hand-rolled check in the root module comparing every instance's resolved port — worth knowing that boundary exists before you assume a module can enforce it alone.
terraform state list shows exactly local_file.service_config and random_id.suffix.search-staging-*.json.modules/service-config/ with typed variables, validation, and defaultsvariable reference.for_each over one locals mapterraform apply reports 6 resources added, one pair per team.terraform state list and see one clean address per instancemodule.service["<key>"].<resource>.grepgrep -RIn 'checkout\|search\|payments' modules/service-config/ returns nothing.environment validation error on a typoterraform plan stops with "environment must be one of: dev, staging, prod."terraform plan stops with a "Resource precondition failed" naming both values.for_each key and watch the plan destroy-and-recreatemoved block and confirm the plan comes back cleanterraform plan shows 0 to add / 0 to destroy, and the file on disk kept its original random suffix.Benny the Beaver: In my defense, the copy-paste version worked. It ran, it applied, green checkmark and everything.
Recon the Robot: I applied exactly what you gave me, Benny. I have no way to know "dev" was supposed to say "staging" — that's not a diff I can compute against anything.
Foxy: So why doesn't the validation block catch it, if you already wrote one for environment?
Recon: Because "dev" is a completely valid value, Foxy. Validation catches nonsense. It can't catch a true statement typed into the wrong file.
Benny: Which is why the module has to exist at all — one map entry, one place that string gets written. No second copy to forget.
Timmy the Turtle: And nobody applies that rename plan without reading it first. "2 to add, 2 to destroy" for a change that touched nothing but a label — that's exactly the kind of plan I stop and question.
Recon: Correctly stopped. A for_each key is an identity to me, not a comment. Tell me explicitly they're the same thing with moved, and I'll believe you — otherwise I take the rename at face value.
1. Why did the copy-pasted second config ship with "environment":"dev" even though it was meant for staging, and why wouldn't the environment variable's validation block have caught it? 2. What does terraform state list show you about a for_each-called module that it never showed you about the hand-copied flat resources? 3. Why does renaming a for_each key from "checkout-dev" to "checkout-development" produce a destroy-and-recreate plan even though nothing about the desired end state actually changed, and what fixes it without touching the real resource? 4. What's the practical difference between a variable's validation block and a resource's lifecycle.precondition block — when do you reach for each?
Check your answers
- Because
"dev"is a completely legitimate value for that field — a validation rule checkingcontains(["dev","staging","prod"], var.environment)happily accepts it, since the bug isn't an invalid value, it's the correct value having been hand-typed into the wrong copy of a duplicated block. Validation catches malformed input; it cannot catch a human copying the wrong line. What actually fixes this class of bug is removing the duplication itself, so the value is written exactly once. - Every address reads
module.service["<key>"].<resource>, where<key>comes directly from the singlelocals.servicesmap — so state itself shows you that these instances are the same kind of thing, called multiple times from one source of truth, instead of N independently hand-named resources with no structural relationship to each other. for_eachuses the map key as the resource's identity in state, not as a comment — a changed key looks exactly like one instance disappearing and an unrelated new one appearing, so Terraform plans a destroy of the old address and a create of the new one even thoughteam_nameandenvironmentnever changed. Amovedblock fixes it by explicitly telling Terraform the old and new addresses refer to the same object, which updates state in place with zero real create or destroy.- A
variable'svalidationblock checks that one input, on its own, is well-formed — a valid enum value, a number in range. A resource'slifecycle.preconditionblock runs against the resource's own configuration and can reason about more than one input together — like "enable_debugmust never be true whenenvironmentis prod," a rule neither variable's own validation could express alone since it depends on both at once.
Module built, called three times, and its state read closely enough to explain every line — that's the whole drill. For the vendor-neutral concepts underneath it (declarative vs. imperative, idempotency, drift, plan-then-apply), see Infrastructure as Code; for the rest of what Terraform specifically offers — remote backends, workspaces, the day-to-day command set — see Terraform. Configuration Management & IaC covers where module design fits into a team's broader infrastructure practice, and you'll see this exact module pattern land in a real multi-resource stack in Capstone Part 2 — Infrastructure as Code. Ready for a different single skill? Try Drill — Fix a Broken Pipeline, or step back to Ship It — Start Here for the six-part continuity version.