By the end you'll know
- How
countmakes several copies of a resource, and the trap that comes with it - How
for_eachmakes copies with names instead of numbers, and why that's safer - How to choose a value with a conditional expression
- How to make a resource exist only sometimes
- How a
forexpression builds a list for an output
Where you are
At the end of Lesson 5, your web server takes its settings from variables. But every resource is still a single thing: one container, one page. Real configurations often need several of something, like a DNS record for each subdomain or a page for each section of a site, and some things only some of the time, like a test server that only exists while you're testing.
Terraform handles both, but not the way most languages do. There's no for statement and no if statement. Instead, repeating and choosing are done with special arguments and expressions, and that's why people find them confusing at first. This lesson takes them one at a time, with a page for each section of your site.
If you did the Python track, here's the map from what you already know:
| The idea | In Python | In Terraform |
|---|---|---|
| Do it once per item | a for loop | for_each on a resource |
| Do it a number of times | a loop with a counter | count on a resource |
| Pick one of two values | an if and else block | condition ? this : that |
| Only sometimes | an if with no else | count = condition ? 1 : 0 |
If you didn't, the everyday examples below are all you need.
count: several numbered copies
Raffle tickets come in a numbered strip: ticket 0, ticket 1, ticket 2 (programmers count from zero). count does that for a resource: put count = 3 on it, and Terraform makes three copies, each with a number.
Step 1. Add a list of page names to variables.tf:
variable "pages" {
description = "Extra pages for the site"
type = list(string)
default = ["home", "about", "contact"]
}
list(string) means "a list, where every item is text", like a Python list. Then add this resource to web.tf:
resource "local_file" "pages" {
count = length(var.pages)
filename = "site/${var.pages[count.index]}.html"
content = "<h1>${var.pages[count.index]}</h1>"
}
count = length(var.pages)makes one copy per item.length()counts the items, so that's 3.count.indexis the copy's number, 0, 1 or 2, different for each copy.var.pages[count.index]picks the matching item from the list, likesites[0]in Python Lesson 3. So copy 0 ishome.html, copy 1 isabout.html, and copy 2 iscontact.html.
Run terraform apply and type yes. Three pages are created in your site folder, and because the container shares that folder, they're live straight away: open http://localhost:8081/about.html. Run terraform state list, and you'll see their addresses, numbered in square brackets:
local_file.pages[0]
local_file.pages[1]
local_file.pages[2]
The count trap
Now remove "about" from the middle of the list in variables.tf, so it's ["home", "contact"], and run terraform plan. You might expect one file to be removed. Instead:
# local_file.pages[1] must be replaced
-/+ resource "local_file" "pages" {
~ filename = "site/about.html" -> "site/contact.html" # forces replacement
then, further down:
# local_file.pages[2] will be destroyed
# (because index [2] is out of range for count)
and Plan: 1 to add, 0 to change, 2 to destroy.
Here's what happened. The copies are known by their numbers, not by what they are. Take about out, and contact moves up from number 2 to number 1. So Terraform sees copy 1 change from about to contact, which means replacing it, and copy 2 disappear. One removal, and a resource that had nothing to do with it gets destroyed and recreated. With pages, that's harmless. With a list of servers, removing one from the middle would rebuild the ones after it, and everything on them.
Don't apply. Put "about" back, so the list is ["home", "about", "contact"] again, and run terraform plan: No changes.
for_each: copies with names
A coat rack with name tags, instead of a numbered strip: when Sam leaves, Sam's peg is empty, and nobody else's coat moves. for_each gives each copy a name, its key, instead of a number.
Step 2. Change the local_file "pages" resource to use for_each:
resource "local_file" "pages" {
for_each = toset(var.pages)
filename = "site/${each.key}.html"
content = "<h1>${each.key}</h1>"
}
for_each = toset(var.pages)makes one copy per item.toset()turns the list into a set: like a list, but with no order and no duplicates, which is whatfor_eachneeds, because every name has to be unique.each.keyis this copy's name:home,aboutorcontact.
Each copy's address now uses its name, like local_file.pages["about"]. That means every address has changed, from numbers to names, so on its own, this change would make Terraform destroy all three pages and create them again. With pages, you could let it. But it's a good chance to use Lesson 3's moved block, the safe way you'd do it with real servers. Add these to web.tf:
moved {
from = local_file.pages[0]
to = local_file.pages["home"]
}
moved {
from = local_file.pages[1]
to = local_file.pages["about"]
}
moved {
from = local_file.pages[2]
to = local_file.pages["contact"]
}
Run terraform plan:
# local_file.pages[0] has moved to local_file.pages["home"]
# local_file.pages[1] has moved to local_file.pages["about"]
# local_file.pages[2] has moved to local_file.pages["contact"]
They may appear in a different order. The summary says Plan: 0 to add, 0 to change, 0 to destroy.: same pages, new addresses. Run terraform apply and type yes.
Now try the removal again. Take "about" out of the list, and run terraform plan:
# local_file.pages["about"] will be destroyed
# (because key ["about"] is not in for_each map)
with Plan: 0 to add, 0 to change, 1 to destroy. Exactly the one you removed, and nothing else touched. That's why, for anything you might add to or remove from, for_each is the safer choice. count is fine when you just want a number of identical things, like "3 workers", where it doesn't matter which one goes. Put "about" back, and run terraform plan: No changes.
A map for more than a name. When each copy needs more than one value, for_each also takes a map, Terraform's version of a Python dictionary. Then each.key is the key, and each.value is its value:
variable "page_titles" {
type = map(string)
default = {
home = "Welcome"
about = "About me"
}
}
With for_each = var.page_titles, the copy for home would have each.key as "home" and each.value as "Welcome". You don't need to add this one; just know it's there when you need it.
Choosing a value
A traffic light picks red or green depending on one thing. A conditional expression picks one of two values depending on a condition:
condition ? value_if_true : value_if_false
Read the ? as "if so, use", and the : as "otherwise, use". It's the whole of Terraform's if and else, in one line.
Step 3. Add a switch to variables.tf:
variable "maintenance_mode" {
description = "Show a 'back soon' page instead of the usual one"
type = bool
default = false
}
bool means true or false. Then, in web.tf, change the content of local_file "page", your main index.html page from Lesson 4, to:
content = var.maintenance_mode ? "<h1>Back soon</h1>" : "<h1>${var.page_text}</h1>"
If maintenance_mode is true, the page says "Back soon"; otherwise, it shows your usual text.
Only sometimes
Some things should only exist some of the time, like a notice that only appears during maintenance. Terraform has no "only if", but count can do it: zero copies means it doesn't exist, and one copy means it does. Add this to web.tf:
resource "local_file" "notice" {
count = var.maintenance_mode ? 1 : 0
filename = "site/notice.html"
content = "<p>Planned maintenance tonight. Back by morning.</p>"
}
The conditional picks 1 or 0, and count makes that many. This one line is the pattern people find strangest in Terraform, because nothing in it says "if". Now that you've seen the parts, read it again: "how many copies? If maintenance mode is on, 1; otherwise, 0."
Turn maintenance mode on for one run:
terraform apply -var maintenance_mode=true
The plan replaces index.html, because its content changed, and adds local_file.notice[0]. Type yes, then refresh http://localhost:8081: "Back soon". Now run plain terraform apply, without the -var, and type yes: the usual page comes back, and the notice is destroyed. One switch, and two changes follow it.
A for expression in an output
One more kind of repeating, this time for values rather than resources. A for expression builds a list from another list. Step 4. Add this to outputs.tf:
output "page_urls" {
description = "The address of every extra page"
value = [for name in var.pages : "http://localhost:${var.external_port}/${name}.html"]
}
Read it as "for each name in the list of pages, make this address". The square brackets around it mean the result is a list. Run terraform apply and type yes:
page_urls = [
"http://localhost:8081/home.html",
"http://localhost:8081/about.html",
"http://localhost:8081/contact.html",
]
Add a page to the list, and its address appears here too, without touching the output.
In my real setup
CivicGrid's Terraform doesn't use any of these yet: it has exactly one of everything, one cluster, one DNS record, one database secret, and repeating code for a single item only makes it harder to read. Where they'd earn their place is the next step. More subdomains, each with its own DNS record, would be a for_each over a map of names to addresses, and a staging cluster that only exists while I'm testing a big change would be count = var.create_staging ? 1 : 0. Use them when you have several of something, not just in case.
Break it on purpose
Try each one, then put it back.
Give for_each a list. In local_file "pages", change toset(var.pages) to just var.pages, and run terraform plan:
Error: Invalid for_each argument
followed by an explanation that ends: the for_each argument must be a map, or set of strings, and you've given it a list of strings. A list can have duplicates and an order, and for_each needs unique names, so Terraform insists you say "treat this as a set". Put toset() back.
Use each where there's no for_each. In local_file "notice", change the filename to "site/${each.key}.html", and run terraform plan:
Error: Reference to "each" in context without for_each
each only exists inside a resource that has for_each; inside a count resource, the equivalent is count.index. Mixing them up is one of the most common Terraform errors. Put the filename back to "site/notice.html".
A duplicate name. Change the list to ["home", "about", "home"], and run terraform plan. No error this time: toset() quietly drops the second "home", because a set can't hold duplicates. So the plan just has two pages, and wants to remove contact, which is no longer in the list. That's usually what you want, but it can surprise you, so it's worth knowing. Put the list back to ["home", "about", "contact"].
Where you are now
You can make several of something with count or for_each, and you know why for_each is safer when items come and go. You can pick values with condition ? this : that, make things exist only sometimes with count = condition ? 1 : 0, and build lists with for expressions. That's the end of the lessons on your own computer. Lesson 7 takes everything you've learned to a real cloud.
Check yourself
What does count = 3 on a resource do?
With count, you remove the middle item of the list. Why is another copy replaced too?
Why does for_each avoid the count trap?
for_each = var.pages fails because var.pages is a list. What's the fix?
What does count = var.maintenance_mode ? 1 : 0 do?
Which line is Terraform's version of if and else?
Key terms
- count: makes a number of copies of a resource, each with a number,
count.index, from 0. - for_each: makes one copy per item in a set or map, each named by its key,
each.key. - Set: like a list, but with no order and no duplicates.
toset()makes one from a list. - Map: names paired with values, like a Python dictionary. With
for_each,each.valueis the value. - Conditional expression:
condition ? this : that, which picks one of two values. - Optional resource:
count = condition ? 1 : 0, so the resource exists only when the condition is true. - for expression:
[for item in list : something], which builds a new list from another one.
Go deeper
- The count meta-argument (opens in a new tab) and the for_each meta-argument (opens in a new tab) in the Terraform documentation, including when to choose which.
- Conditional expressions (opens in a new tab) and for expressions (opens in a new tab).