By the end you'll know
- What a module is, and how to write and use your own
- How to use a module from the Terraform Registry, with a pinned version
- What version constraints like
~>mean, and what the lock file is for - How
fmtandvalidatekeep a project tidy - How
lifecyclesettings protect important resources - Where Terraform's job ends
Where you are
You've now used everything Terraform needs day to day: plans, state, providers, dependencies, variables, outputs, loops, a real cloud and shared state. The last lesson is about what happens as a project grows. Configurations get longer, the same pattern gets copied from one place to another, provider versions drift, and one careless change can destroy something important.
This lesson covers the habits that stop that: modules for reuse, pinned versions, tidy files, a few safety settings, and knowing what Terraform shouldn't be doing at all. It goes back to your terraform-practice folder, so it's free again.
Modules: write it once
In Python Lesson 4, you moved repeated steps into a function, so you wrote them once and called them by name. A module is the same idea for Terraform: a folder of .tf files with its own inputs and outputs, which other configurations can use by name.
You've been writing one all along without knowing it. Every folder you've run Terraform in is a module, called the root module. What's new is writing a second one for the root module to use, called a child module.
Step 1. In terraform-practice, make a folder for the module:
mkdir -p modules/web-page
-p creates the modules folder and the web-page folder inside it in one go. In modules/web-page, create a file called main.tf:
variable "name" {
description = "The page's name, used for its file name"
type = string
}
variable "heading" {
description = "The heading shown on the page"
type = string
}
resource "local_file" "page" {
filename = "site/${var.name}.html"
content = "<h1>${var.heading}</h1>"
}
output "path" {
description = "Where the page was written"
value = local_file.page.filename
}
Nothing in this is new: two variables, one resource, one output. That's the point. A module's variables are its inputs, like a function's parameters, and its outputs are what it hands back, like return. Bigger modules usually split these into variables.tf, main.tf and outputs.tf, as you did in Lesson 5.
Step 2. Use it. In web.tf, replace the whole local_file "pages" resource from Lesson 6, the one with for_each, with this:
module "pages" {
source = "./modules/web-page"
for_each = toset(var.pages)
name = each.key
heading = each.key
}
sourcesays where the module is../modules/web-pagemeans a folder inside this one.for_eachworks on modules exactly as on resources, so you get one copy of the module per page.nameandheadingare the module's inputs. Here both are just the page's name, but you could pass anything.
The pages are now inside the module, so their addresses change, from local_file.pages["about"] to module.pages["about"].local_file.page. With files it wouldn't matter if Terraform recreated them, but this is the last chance to practise doing it safely, with Lesson 3's moved blocks. Lesson 6 left three moved blocks in web.tf, from numbers to names; delete them, since that move has been applied, and add these:
moved {
from = local_file.pages["home"]
to = module.pages["home"].local_file.page
}
moved {
from = local_file.pages["about"]
to = module.pages["about"].local_file.page
}
moved {
from = local_file.pages["contact"]
to = module.pages["contact"].local_file.page
}
A new module needs installing, so run init, then plan:
terraform init
terraform plan
init now includes a line like - pages in modules/web-page: it found your module. The plan shows each page "has moved to" its new address, and Plan: 0 to add, 0 to change, 0 to destroy. Run terraform apply and type yes, then terraform state list to see the new addresses.
Finally, a module's outputs are read as module.<name>.<output>. Add an output to outputs.tf that comes from the module itself:
output "page_paths" {
description = "Where every extra page was written"
value = [for page in module.pages : page.path]
}
Add it, apply, and you'll see page_paths listing all three files.
When a module helps, and when it doesn't. A module earns its place when the same group of resources is used more than once, or when it hides genuine complexity behind a few clear inputs. A module wrapping a single resource, used once, just adds a folder to look in. Like functions, write them when you notice you're repeating yourself, not in advance.
Modules other people wrote
The Terraform Registry has thousands of published modules, for things that take many resources to build properly. For example, a network on AWS needs a dozen or more resources wired together correctly, so most teams use the community's VPC module instead of writing their own. Using one looks like this; you don't need to run it:
module "network" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 6.7"
name = "practice"
cidr = "10.0.0.0/16"
}
Two differences from your own module. The source is a Registry address, not a folder. And there's a version, which you should always set for a module from the Registry, so an update to someone else's code can't change your infrastructure without you choosing it. Before using a published module, look at its Registry page: how widely it's used, how recently it was updated, and whether it's from a source you trust.
Version constraints and the lock file
You've written version = "~> 4.6" since Lesson 4. Here's what that and its relatives mean:
| Constraint | Allows |
|---|---|
= 4.6.0 | exactly 4.6.0, and nothing else |
>= 4.6 | 4.6 or anything newer, including 5.0 and beyond |
~> 4.6 | 4.6 or newer, but below 5.0 |
~> 4.6.0 | 4.6.0 or newer, but below 4.7 |
~> is the usual choice. Version numbers like 4.6.0 follow a common convention: the first number goes up for breaking changes, the second for new features, and the third for fixes. So ~> 4.6 accepts features and fixes, but not a new major version that might break your configuration.
A constraint allows a range, but every init still has to pick one exact version. That choice is written in .terraform.lock.hcl, the file Lesson 1 said would come up later. Open it: for each provider, it records the exact version chosen and checksums of the download. From then on, init uses exactly those versions, on your computer, a colleague's, or HCP Terraform's, until you choose to upgrade with:
terraform init -upgrade
which picks the newest versions your constraints allow, and updates the lock file. So commit .terraform.lock.hcl to Git, and change it on purpose. It's the same idea as pinning nginx:1.28 in the Docker track: what you tested is what runs.
Tidy files: fmt and validate
Two commands keep a project readable and catch mistakes early, without touching any infrastructure.
terraform fmt rewrites your files in Terraform's standard layout: two-space indents, = signs lined up. Mess up the spacing in web.tf on purpose, then run it:
terraform fmt
It prints the names of the files it changed, and the spacing is fixed. Everyone's files look the same, so a change in Git shows only what really changed.
terraform validate checks that your configuration makes sense, references and types included, without contacting any provider:
terraform validate
Success! The configuration is valid.
Teams often run terraform fmt -check and terraform validate automatically on every pull request. -check changes nothing; it just fails if any file needs formatting.
Protecting important things: lifecycle
A lifecycle block, inside a resource, changes how Terraform treats it. Three settings come up often:
create_before_destroy = truereverses the order of a replacement: build the new one first, then destroy the old one. That's the+/-symbol Lesson 2 mentioned. It's for things that mustn't have a gap, like a server behind a website.prevent_destroy = truemakes Terraform refuse any plan that would destroy the resource. It's a guard rail for things like a production database: removing the guard has to be a deliberate change.ignore_changes = [...]tells Terraform to stop correcting particular settings that are changed elsewhere, on purpose.
The break-it section tries prevent_destroy.
Where Terraform stops
Terraform is very good at creating and changing infrastructure: networks, servers, clusters, DNS records, the places things run. It's a poor fit for what changes many times a day inside them, like deploying each new version of an app, which is the job of tools built for it. A useful rule of thumb: Terraform builds the stage; other tools put on the show.
In my real setup
CivicGrid's Terraform draws exactly that line. It builds the Kubernetes cluster, creates the DNS record, stores the app's secrets, and installs ArgoCD, a tool that deploys apps to Kubernetes from a Git repository. Then it stops. Every new version of the API, every config change and every new piece of monitoring goes through ArgoCD and Git, not Terraform. My Terraform changes a few times a year; the app changes every week. Its README says it plainly: after terraform apply completes, ArgoCD takes over.
Break it on purpose
Guard a page. In modules/web-page/main.tf, add a lifecycle block inside the local_file "page" resource, after content:
lifecycle {
prevent_destroy = true
}
Then remove "about" from the pages list in variables.tf, and run terraform plan:
Error: Instance cannot be destroyed
followed by the resource's address, module.pages["about"].local_file.page, and an explanation that prevent_destroy is set but the plan would destroy it. Terraform refuses the whole plan, not just that one resource. To really remove it, you'd first have to take the guard off, which is exactly the deliberate step it's there to force. Put "about" back, and delete the lifecycle block.
Use a module before installing it. Add a second module block to web.tf, using the same module:
module "extra_page" {
source = "./modules/web-page"
name = "faq"
heading = "Questions"
}
and run terraform plan without running init:
Error: Module not installed
followed by an explanation that this module isn't installed yet, and to run terraform init. Every new module block needs an init, even for a module on your own computer. Run terraform init, then terraform plan: one page to add. Apply it if you like, then delete the block and apply again to remove it.
Forget an input. In the extra_page block, before you delete it, remove the heading line, and plan:
Error: Missing required argument
followed by The argument "heading" is required, but no definition was found. The module's heading variable has no default, so like any required variable, it must be given. It's the module version of the missing argument in Python Lesson 4's to_ms().
When you're done with the track, terraform destroy in terraform-practice removes the container, the image and the pages, and frees port 8081.
Where you are now
That's the whole track. You started with one file on your own computer; you can now describe real infrastructure in code, read a plan with confidence, look after state, wire resources together in the right order, take settings from outside the code, repeat and choose, build in a real cloud and clean up, share state with a team, and package and protect what you've built.
Good next steps from here:
- Build something bigger in the cloud: the Kubernetes cluster guide listed on this topic page, once it's published, uses everything in this track.
- Bring existing infrastructure under Terraform, with
importblocks, for things that were clicked into existence before you started. - Run plans automatically on every pull request, with GitHub Actions or HCP Terraform's VCS-driven workflow from Lesson 8.
Check yourself
What is a Terraform module?
What does version = "~> 6.7" allow?
Why commit .terraform.lock.hcl to Git?
What does terraform fmt do?
A resource has prevent_destroy = true. What happens if a plan would destroy it?
In CivicGrid, where does Terraform's job end?
Key terms
- Module: a folder of
.tffiles with inputs and outputs, used by other configurations. - Root module: the folder you run Terraform in. Child module: one it uses with a
moduleblock. - source: where a module comes from: a folder, like
./modules/web-page, or a Registry address. - Version constraint: the versions allowed, like
~> 4.6. Always set one for providers and Registry modules. - Lock file:
.terraform.lock.hcl, which records the exact versions chosen. Commit it. - terraform fmt: rewrites files in the standard layout. terraform validate: checks they make sense.
- lifecycle: settings that change how Terraform treats a resource, like
create_before_destroyandprevent_destroy.
Go deeper
- Modules (opens in a new tab) in the Terraform documentation, from using them to writing your own.
- Version constraints (opens in a new tab) and the dependency lock file (opens in a new tab).
- The lifecycle meta-argument (opens in a new tab).
- The Terraform Registry (opens in a new tab), to browse published modules.