Terraform basics · Lesson 9 of 9 · about 30 minutes

Modules and tidy habits

How to package Terraform into reusable modules, use modules other people have published, pin versions so nothing changes by surprise, and keep a growing project tidy, safe and honest about where Terraform should stop.

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 fmt and validate keep a project tidy
  • How lifecycle settings 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:

BASH
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:

HCL
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:

HCL
module "pages" {
  source   = "./modules/web-page"
  for_each = toset(var.pages)

  name    = each.key
  heading = each.key
}
  • source says where the module is. ./modules/web-page means a folder inside this one.
  • for_each works on modules exactly as on resources, so you get one copy of the module per page.
  • name and heading are 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:

HCL
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:

BASH
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:

HCL
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:

HCL
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:

ConstraintAllows
= 4.6.0exactly 4.6.0, and nothing else
>= 4.64.6 or anything newer, including 5.0 and beyond
~> 4.64.6 or newer, but below 5.0
~> 4.6.04.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:

BASH
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:

BASH
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:

BASH
terraform validate
OUTPUT
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 = true reverses 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 = true makes 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.

A module, used three timesThe root module, your terraform-practice folder, has a module block called pages with for_each over home, about and contact. Each copy passes name and heading into the web-page module in modules/web-page, which creates one local_file and hands back its path as an output. The root module collects the three paths into the page_paths output.root module: terraform-practicemodule "pages"for_each: home, about, contactoutput "page_paths"name, heading inpath outchild module: modules/web-pagehomeone pageaboutone pagecontactone pageWrite the module once. Call it as many times as you need, with different inputs.
Like a function: values go in as variables, the module does its one job, and results come back as outputs. for_each calls it once per page.

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:

HCL
  lifecycle {
    prevent_destroy = true
  }

Then remove "about" from the pages list in variables.tf, and run terraform plan:

OUTPUT
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:

HCL
module "extra_page" {
  source  = "./modules/web-page"
  name    = "faq"
  heading = "Questions"
}

and run terraform plan without running init:

OUTPUT
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:

OUTPUT
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 import blocks, 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

  1. What is a Terraform module?

  2. What does version = "~> 6.7" allow?

  3. Why commit .terraform.lock.hcl to Git?

  4. What does terraform fmt do?

  5. A resource has prevent_destroy = true. What happens if a plan would destroy it?

  6. In CivicGrid, where does Terraform's job end?

Key terms

  • Module: a folder of .tf files with inputs and outputs, used by other configurations.
  • Root module: the folder you run Terraform in. Child module: one it uses with a module block.
  • 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_destroy and prevent_destroy.

Go deeper