Learning objectives
By the end of this module, you should be able to:
- Assemble a reusable module with stable instance identity.
- Add validation and tests before apply.
- Demonstrate plan, apply, no-change verification and controlled cleanup.
A good root module composes capabilities
The root module chooses environment-specific values and composes reusable child modules. Child modules focus on one capability with explicit input/output contracts.
Stable identity and validation reduce change risk
for_each keys express identity explicitly, validation rejects unsupported inputs, and tests catch contract regressions before a production plan.
The lifecycle closes with evidence and cleanup
A complete workflow includes formatting, validation, tests, plan review, apply, output inspection, no-change verification and destroy/retirement planning.
Worked examples
See the idea in practice.
Capstone architecture
variables -> locals -> module.service[for_each] -> terraform_data -> outputs
|
+-> terraform test -> plan -> apply -> no-change plan
- No cloud account is required, so the learner can focus on Terraform semantics rather than provider credentials.
Hands-on lab
Lab — Build a reusable tested service catalogue
Goal: Create a root module that instantiates a child service module by stable keys and proves its contract with terraform test.
Before you start
- Terraform 1.6+; no external providers or cloud credentials required.
STEP 1Create child module
mkdir -p ~/gnu-terraform-labs/module12/modules/service ~/gnu-terraform-labs/module12/tests && cd ~/gnu-terraform-labs/module12
cat > modules/service/main.tf <<'EOF'
variable "name" {
type = string
}
variable "tier" {
type = string
}
resource "terraform_data" "this" {
input = {
name = var.name
tier = var.tier
}
}
output "descriptor" {
value = terraform_data.this.output
}
EOF
STEP 2Create root configuration
cat > main.tf <<'EOF'
variable "environment" {
type = string
default = "dev"
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "unsupported environment"
}
}
variable "services" {
type = map(string)
default = {
api = "frontend"
worker = "backend"
}
}
module "service" {
for_each = var.services
source = "./modules/service"
name = "${var.environment}-${each.key}"
tier = each.value
}
output "catalogue" {
value = { for k, m in module.service : k => m.descriptor }
}
check "service_set" {
assert {
condition = length(var.services) >= 2
error_message = "at least two services expected"
}
}
EOF
STEP 3Add a contract test
cat > tests/catalogue.tftest.hcl <<'EOF'
run "catalogue_contract" {
command = apply
variables {
environment = "prod"
}
assert {
condition = output.catalogue.api.name == "prod-api"
error_message = "api naming contract failed"
}
assert {
condition = output.catalogue.worker.tier == "backend"
error_message = "worker tier contract failed"
}
}
EOF
STEP 4Validate, test and apply
terraform init
terraform fmt -recursive
terraform fmt -check -recursive
terraform validate
terraform test
terraform plan -out=tfplan
terraform apply tfplan
STEP 5Prove convergence
set +e
terraform plan -detailed-exitcode
rc=$?
set -e
test "$rc" -eq 0
terraform output catalogue
Verify
- terraform test passes.
- The applied catalogue contains api and worker keyed instances.
- The final plan returns no changes.
Expected outcome
- You have a provider-free capstone demonstrating the mechanics used by real Terraform modules and pipelines.
If it fails
- If fmt -check fails, run terraform fmt -recursive.
- If a test fails, read the assertion message before changing infrastructure.
Real-world connectionThe next step is to swap the built-in training resource for a real provider module while preserving the same contracts, tests, remote-state and review discipline.
Avoid these traps
Common mistakes
- Skipping no-change verification after apply.
- Treating a green apply as proof that module contracts are correct.
- Mixing unrelated infrastructure capabilities into one unbounded module.
Knowledge check
Can you explain it without looking back?
Open each answer only after you have tried to answer the question yourself.
1What does convergence mean in this capstone?
After apply, a fresh plan reports no further changes.
2Why use for_each in the service catalogue?
Service names are stable identity keys.
3What should replace terraform_data in a real deployment?
Provider-backed resources inside modules, while keeping the same safe workflow and interface principles.