Home/Terraform & OpenTofu
Lab · Manual of Terraform & OpenTofu

Terraform & OpenTofu

The architect who designs digital cities.

22 exercises6 Parts5 appendices
The colour code shows the level:FoundationalIntermediateAdvancedCloud Architect
get the exercises
git clone --filter=blob:none --sparse https://github.com/calmict/book_labs.git
cd book_labs
git sparse-checkout set terraform-opentofu/ed1
calm@calmict:~$ cat terraform/topics
Part 1

Conceptual foundations

  • 01The snowflake and the herdFoundational
  • 02The recipe and the photographFoundational
  • 03Renovate or rebuildFoundational
  • 04The invisible foremanFoundational
Part 2

The language and first use

  • 05The skyscraper's datasheetFoundational
  • 06The first stoneFoundational
  • 07The version registerFoundational
  • 08One translator, two sitesFoundational
Part 3

Resources, data and state

  • 09Arguments, attributes and the art of turning a blind eyeIntermediate
  • 10The land registryIntermediate
  • 11The notebook and its secretsIntermediate
  • 12One notebook, with a lockIntermediate
  • 13The fire doorsIntermediate
Part 4

Abstraction and reuse

  • 14The three doorsIntermediate
  • 15The fleet: by number or by nameIntermediate
  • 16The workbenchIntermediate
  • 17The prefabIntermediate
Part 5

Evolution and maintenance

  • 18The paperwork, not the buildingsAdvanced
  • 19The drawer or the roomAdvanced
Part 6

Ecosystem, quality and production

  • 20The twins and the lockCloud Architect
  • 21The pyramid of checksCloud Architect
  • 22The conveyor beltCloud Architect
calm@calmict:~$ ls terraform/exercises/
01The snowflake and the herdFoundational

What you build

Before learning the syntax, you need to *feel* the problem. In this exercise you build two "servers" by hand, the way it used to be done (and sadly still is) with click-ops: you will find they diverge immediately. Then you describe them as code: the same snapshot of the result for both. From there on you torture the infrastructure — you hand-edit it in the middle of the night, you delete a piece of it, you raze it to the ground — and every time a single command brings it back exactly to the model. By the end, a "server" is no longer a pet with a name and a personal history: it is a head of cattle with a tag, replaceable at any moment.

The "servers" here are plain configuration files on your disk: zero cloud, zero cost, but the concepts — drift, idempotence, convergence, immutable identity — are exactly the ones you will meet in production.

Objectives

  • recognise configuration drift and explain why click-ops inevitably produces it;
  • tell the imperative approach ("run these steps") from the declarative one ("this is the result I want");
  • watch idempotence in action: applying twice changes nothing;
  • see convergence: hand-modified reality returns to the model;
  • explain the difference between pets and cattle with a concrete example.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap01/solution
./run.sh

Expected result

  • Phase 2 answers exactly: No changes. Your infrastructure matches the configuration.
  • After the Phase 3 sabotage, one apply brings debug_mode back to off without you touching the file by hand.
  • After destroy + apply (Phase 5) both files exist again, identical to each other, with a herd_tag different from the one you noted down.
  • You answered the three questions in answers.md.
02The recipe and the photographFoundational

What you build

A recipe lists the steps: crack the eggs, heat the pan, pour. A photograph shows the finished dish. In chapter 1 you *felt* the drift; here you put your hands on the split that generates it: you actually write an imperative provisioning script, watch it blow up on the second run, repair it by adding guards ("if it exists, skip") — and then discover its fatal flaw: guards make the script re-runnable, but *blind*. A vandalised file walks past the guard undisturbed, because the guard checks that the file *exists*, not that it is *right*.

Then you photograph the same fleet in a main.tf and torture it from four different starting points — empty yard, half-built, vandalised, already finished — always with the same identical command. You no longer write the steps: the tool computes them, every time, by comparing reality with the model.

Objectives

  • explain why a script of steps only works from the starting point its author had in mind;
  • build idempotence by hand with guards, and measure its cost;
  • tell re-runnability from convergence: guards buy you the first, not the second;
  • watch the same command produce different plans from different starts, and the same result from all of them;
  • recognise the tasks for which the recipe remains the right tool.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap02/solution
./run.sh

Expected result

  • The repaired provision.sh survives two consecutive runs without errors.
  • After the Phase 2 sed, the guarded script LEAVES debug on (this is the expected behaviour: it is the demonstration, not a bug).
  • From the half-built start, tofu plan proposes exactly 2 resources to re-create, and after the apply the inventory lists the three servers again.
  • The last apply answers: No changes.
  • You answered the three questions in answers.md.
03Renovate or rebuildFoundational

What you build

Facing a building that must change, the architect has two roads: renovate (the building stays up, one system gets changed) or demolish and rebuild (a new building takes the old one's place). Infrastructure works the same way, and the remarkable thing is that *you do not decide* which road is taken: the provider knows it, attribute by attribute — and the plan always announces it in advance, with precise signage that this exercise teaches you to read.

Here the "server" is for the first time a living thing: a Docker container running nginx. You change its memory and watch it remain the same object (renovation, in-place). Then you change the image version and watch it *die and be reborn* (reconstruction, replace): nobody ever stepped inside that container to upgrade nginx — this is, literally, immutability. Finally you take the lifecycle block in hand and govern the replacement: first you flip the order (build the new one, then demolish the old one), then you engage the safety catch that blocks any demolition — and discover it blocks more than you thought.

Objectives

  • read in the plan which road was chosen: the tilde of the in-place update, the -/+ of the replace, the marker that says exactly *which* attribute forces the replacement;
  • explain why the provider decides the road, attribute by attribute;
  • flip the replacement order with create_before_destroy, and state which condition on identity makes it possible;
  • use prevent_destroy as a safety catch, knowing it blocks replacements too;
  • tie drift (ch. 1), convergence (ch. 2) and immutability into one thread.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap03/solution
./run.sh

Expected result

  • After Phase 1 the container ID is identical to before the apply (you verified it with docker inspect).
  • In the Phase 2 plan you spotted the "# forces replacement" marker and the destroy and then create replacement announcement.
  • In the Phase 3 plan the order is flipped: create replacement and then destroy.
  • In Phase 4 both the destroy and the version-bump plan fail with Instance cannot be destroyed.
  • You answered the three questions in answers.md.
04The invisible foremanFoundational

What you build

Across the last three chapters one question stayed open: when resources are many and linked, who decides *in which order* to build them? Not you — you never wrote an order anywhere. An invisible foreman decides: the dependency graph, which the tool builds by reading your code.

In this exercise you make it visible with the most honest instrument there is: a stopwatch. You build three floors of 5 seconds each *without telling the model they are a tower*: they all go up together, 5 seconds total — physically absurd, but the model does not know one floor rests on another until the code says so. Then you chain the floors with references and look at the stopwatch again: 15 seconds, one at a time. Same three resources, no "order" written anywhere: only edges born from references. Finally you look the graph in the face with tofu graph, watch the demolition proceed backwards, and try to build the one thing the foreman refuses: the cycle — the chicken born from the egg that is laid by the chicken.

Objectives

  • explain where the graph's edges come from: the reference is the edge (implicit dependency), depends_on is the hand-declared edge (explicit);
  • measure parallelisation: why what is unlinked travels together, and what is linked waits;
  • read the output of tofu graph and find your own edges;
  • predict the demolition order: the same graph, walked backwards;
  • recognise the forbidden cycle and explain why it is rejected *before* touching reality.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap04/solution
./run.sh

Expected result

  • The Phase 0 apply took about 5 seconds; the Phase 1 apply about 15 (you measured both with time).
  • In tofu graph you spotted your three edges: floor_2 -> floor_1, floor_3 -> floor_2, certificate -> floor_3.
  • During demolition the order was reversed: certificate and floor_3 first, floor_1 last.
  • tofu validate in cycle/ fails with Error: Cycle and the names of the two resources.
  • You answered the three questions in answers.md.
05The skyscraper's datasheetFoundational

What you build

Across the first four chapters you *read* HCL, guided by the comments. From this chapter on you *write* it. No more torturing the infrastructure: this is architect's desk work — filling in a skyscraper's technical datasheet using, one by one, every data type in the language: the primitives for the records, a list for the materials (where a deliberately planted duplicate will show you the difference from a set), a map for the areas, an object for the address, a tuple for the coordinates. Then you assemble everything into a text block with interpolation: the datasheet itself, which the apply deposits into a file.

The chapter closes with the humblest and most used tool of the trade: tofu fmt, put to the test on a file written by a sloppy colleague — valid but unreadable. You will discover what fmt always fixes (the form) and what it never touches (the meaning).

Objectives

  • tell at a glance a block (type, labels, body) from an argument (name = expression), and recognise nested blocks;
  • pick the right type: list when order matters and duplicates are allowed, set when not; map for homogeneous keys, object for mixed structures, tuple for positional data;
  • use the four access syntaxes: local.x, local.obj.field, local.map["key"], local.tuple[0];
  • write a heredoc with <<-EOT and fill it with interpolations;
  • use tofu fmt (-diff, -check) and state exactly what it may change and what it may not.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap05/solution
./run.sh

Expected result

  • tofu validate passed after each completed TODO (the file was never broken).
  • In the outputs: "steel" appears twice in materials and once in unique_materials, which is alphabetically ordered and labelled toset.
  • datasheet.txt contains the expected lines, with the values extracted from object, map and tuple through their respective access syntaxes.
  • The second apply answers No changes.
  • After tofu fmt, tofu fmt -check reports nothing.
  • You answered the three questions in answers.md.
06The first stoneFoundational

What you build

Five chapters of concepts and guided exercises: now you lay your own first stone. In this exercise you write from scratch — line by line, no more placeholders — your first complete configuration: the terraform block that declares the translators, the provider block that configures them, the resources, an output. The result is not a file on disk: it is a real web service, reachable with a browser, switched on from code.

And above all you live the lifecycle in slow motion, looking where so far you rushed past: what init *really* downloads (you will go and weigh the provider binary inside .terraform: surprise), what a saved plan is and why executing it asks for no confirmation, which everyday questions are answered by state list, show and output, and what destroy demolishes — and what it leaves standing.

Objectives

  • explain "two binaries, one language": where the binary you installed ends and where the providers that init installs begin;
  • write a complete configuration: terraform, provider, resource, output;
  • use the saved plan (plan -out + apply of the file) and say why it asks for no confirmation;
  • answer the three everyday questions with state list, show and output;
  • state precisely what destroy removes and what it does not.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap06/solution
./run.sh

Expected result

  • You wrote the whole main.tf yourself, and tofu validate passed after every block.
  • You found and weighed the provider binary inside .terraform (tens of MB) and watched .terraform.lock.hcl being born.
  • tofu apply first.plan started without asking for confirmation, and curl on 8087 answered with the welcome page.
  • The port change produced a replace (# forces replacement marker), not an update.
  • After the destroy: state list empty, but .terraform and the lock file still present.
  • You answered the three questions in answers.md.
07The version registerFoundational

What you build

A serious construction site has a specification book: which standards apply, which suppliers are admitted, and a register of *exactly* which materials were chosen. In a project, the specification book is the terraform block — and in this exercise you put it to the test by breaking and repairing it: an impossible required_version slams the gate in your face (and that is a good thing: you will discover what it protects from), an exact pin shows you the birth of the lock file, and then the game gets subtle — you widen the constraint with the ~> operator and discover that *nothing changes*, until you ask for it yourself with init -upgrade.

It is the separation of powers that holds teamwork together: the *constraint* in the code is the fence (what would be acceptable), the *lock* is the choice (what we all actually use, today). The exercise closes with the July colleague: he deletes the register, re-runs init, and gets a different translator from yours — same code, months later, different provider. It is the drift of chapters 1 and 2, climbed up from the world of servers to the world of tools.

Objectives

  • explain what the terraform block is for and what required_version protects from;
  • read and choose semver operators, and state what ~> 3.5 promises (and forbids);
  • tell the separation of powers: constraint = fence, lock = choice;
  • use init -upgrade as a deliberate gesture, and read the conflict error between constraint and lock;
  • say why in a real project the lock file must be committed (and why in this exercise repo, exceptionally, it is gitignored).

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap07/solution
./run.sh

Expected result

  • Phase 0's init failed with Unsupported OpenTofu Core version, and after TODO 1 it passed.
  • In the lock file you spotted version, constraint and hashes.
  • After TODO 2 (~> 3.5), init answered Reusing previous version ... from the dependency lock file, staying on 3.5.1.
  • After init -upgrade the lock's diff shows the new version.
  • Phase 4's conflict produced the error with the must use tofu init -upgrade indication.
  • Without the lock (Phase 5), init installed the latest 3.x directly.
  • You answered the three questions in answers.md.
08One translator, two sitesFoundational

What you build

In chapter 6 you weighed the translator: a binary of tens of megabytes inside .terraform. But the binary alone is not enough: you must tell it *which world* to talk to — and that is the provider block's trade. Here you discover it in the most concrete way possible: you build a second datacenter on your machine (a Docker inside Docker: a real, separate engine, reachable over the network) and configure *two instances of the same translator* — the default line towards the Milan site and an aliased line towards the Frankfurt one. Then you place the same nginx in both, deciding the destination resource by resource with a single line: provider =.

The second half of the chapter is to be read, not executed: a gallery of real provider blocks — AWS with roles, vSphere with the password in the wrong place and then in the right one — leading to the golden rule: the code says *where* and *how* to connect, never *who you are*: secrets live outside the code, always.

Objectives

  • tell the provider-binary (the translator init installs) from the provider block (the configured line towards a real system);
  • declare multiple instances of the same provider with alias, and place each resource with the provider = meta-argument;
  • read an AWS provider block with assume_role and explain why roles beat static keys;
  • spot the capital sin (credentials in code) at a glance, and its fix;
  • say what tofu destroy removes in a multi-provider scenario — and what it does not touch.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap08/solution
./run.sh

Expected result

  • The two engines answered separately (docker info locally and via tcp://127.0.0.1:23750).
  • tofu state list showed 4 resources; docker ps saw one per engine (plus the dind, Milan side).
  • Both curls (8091 and 8092) answered with the nginx page.
  • After the destroy: Frankfurt's engine empty, but the cap08-frankfurt-dc container still alive — then removed by hand.
  • You read the two .tf.example files and can point at the capital sin and its fix.
  • You answered the three questions in answers.md.
09Arguments, attributes and the art of turning a blind eyeIntermediate

What you build

The resource is the brick everything is made of, and this chapter puts it on the operating table. The first discovery is that it has two faces: the *arguments* — what you write, the input — and the *attributes* — what the resource gives back once born, the output: the id, the IP address Docker assigned it, the values nobody knew before the apply. You build a dossier that consumes precisely those outputs, and in the plan you see the trail they leave: (known after apply), the declared unknown travelling along the graph.

The second discovery completes chapter 3's lifecycle with its subtlest piece: ignore_changes. The night team changes a setting of your container by hand; the plan — faithful to chapters 1 and 2 — wants to converge it back. But this time the change is *legitimate*: that knob belongs to another process. You will sign the blind-eye contract, and learn when it is wisdom and when it is just a patch.

Objectives

  • tell arguments (input) from attributes (output), and say when attributes are born;
  • read (known after apply) as a value that exists but is not yet knowable — and watch it propagate along references;
  • use ignore_changes to tolerate a specific drift by contract, and explain its risks;
  • list the meta-arguments met so far (provider, depends_on, lifecycle) and each one's trade;
  • read the extended AWS and vSphere examples recognising inputs, outputs and edges.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap09/solution
./run.sh

Expected result

  • In Phase 1's plan the dossier's content was (known after apply); after the apply dossier.txt contains real id and IP.
  • Phase 2's plan showed ~ restart "unless-stopped" -> "no".
  • After TODO 2: plan answers No changes AND docker inspect still shows unless-stopped (the drift is there, the contract tolerates it).
  • You can point at three arguments and three attributes in your main.tf.
  • You answered the three questions in answers.md.
10The land registryIntermediate

What you build

No construction site starts on virgin ground: there is the neighbourhood's network, the waterworks, the land registry recording what exists. So far everything in your model was created by you; in this chapter you learn to *consult* — to read what exists, belongs to others, and is not yours to manage.

The platform team created a Docker network (by hand: Phase 0, you play them). You read it with a data block — the scouts announced in chapter 9's gallery — and lean your container on it: you build *on* what you do not own. Along the way you discover chapter 9's reverse: a data source's attributes are known *already at plan* — the existing is consulted right away, there is nothing to wait for — except when the data depends on a resource yet to be born: then the read slips to the apply, and the unknown returns. You will see the two cases side by side, in the same plan. And at destroy, the proof that closes the chapter: what you read is not yours — the platform's network survives intact.

Objectives

  • write a data block and explain how it differs from a resource (reading vs owning);
  • build your own resources on top of other people's objects, without managing them;
  • predict when a data source is read at plan and when it slips to apply — and recognise both cases in the plan;
  • say what appears in state list with the data. prefix and what happens to it at destroy;
  • name a few classic data sources of the real worlds (the gallery).

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap10/solution
./run.sh

Expected result

  • In Phase 1's plan: Read complete during the plan, and the netcard's content already resolved (no known after apply).
  • The container is attached to cap10-platform-net with an IP in 172.28.x.
  • In the same Phase 3 plan: netcard resolved AND freshcard (known after apply) — you can explain the difference.
  • After the destroy: your 5 resources gone, cap10-platform-net still in docker network ls.
  • You answered the three questions in answers.md.
11The notebook and its secretsIntermediate

What you build

For ten chapters you have used plan and apply, and one character has been working in the shadows at every command: the notebook where the tool records what it built and what it was called in the code. In this chapter you open it and read it: terraform.tfstate, the mapping between the model's addresses and the real objects — with even the graph's edges inside.

Then three discoveries, in crescendo. The first one burns: you create a password marked sensitive, the output hides it — and the notebook keeps it *in plain text*: whoever reads the state reads every secret. The second is the game of the three sources of truth: code, memory, reality — you delete a container behind the model's back and learn the command that syncs *only the memory* (plan and apply -refresh-only), separating "update the notebook" from "touch the world". The third is the finale that sets up chapter 12: a colleague clones your code but not your memory — his plan wants to rebuild everything, his apply crashes into the reality that already exists, and his notebook is left half-written. Same code, two memories, one reality: it is the problem that only *shared* state solves.

Objectives

  • explain which problem the state solves: the code-address ↔ real-object binding that neither the code nor reality contains;
  • find your way inside terraform.tfstate: version, serial, lineage, resources, attributes, dependencies;
  • demonstrate that sensitive protects the *output*, not the *state* — and draw the operational consequences;
  • use plan/apply -refresh-only to realign the memory without touching reality;
  • tell, with a concrete example, why two memories over the same world lead to collision.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap11/solution
./run.sh

Expected result

  • You can point at, inside the tfstate: serial, lineage, the docker_container.web → real id mapping, and the dependencies entry.
  • You saw the same password <sensitive> in the output and in plain text in the state.
  • The plan -refresh-only showed "has been deleted" and the apply -refresh-only updated ONLY the memory (state list without the container, reality untouched).
  • The colleague: "3 to add" plan, apply failed with Conflict, partial state list (password and image).
  • You answered the three questions in answers.md.
12One notebook, with a lockIntermediate

What you build

Chapter 11 closed on an incident: two colleagues, two notebooks, one contested reality. The solution has a name — backend — and it is the answer to the question "where does the state live?". In this exercise you build the answer: the platform team (you again, helmet on) switches on the site's noticeboard — a Consul in a container, a real remote backend — and you *move* your notebook in there with the official manoeuvre: the backend block plus init -migrate-state. You will verify in person that the local file emptied and that the state now lives in the backend (with, still in plain text, what you know from chapter 11 inside: the house changed, the custody rules did not).

Then the colleague returns — and this time the story is different: he attaches to the same backend and his first plan says No changes: *he sees your resources*, because he reads your very memory. And the grand finale: while one of your applies is running, he tries to work — and the lock stops him, with a full name tag: Error acquiring the state lock, stating who holds it and for which operation. Chapter 11's chaos has become an orderly queue.

Objectives

  • explain what the backend block governs (where the state lives, who can read it, how writes are serialised);
  • migrate a local state into a remote backend with init -migrate-state, and verify the outcome from both sides;
  • attach a second collaborator to the same state and demonstrate that chapter 11's incident can no longer happen;
  • read the lock error (ID, path, operation, who) and know that force-unlock exists as break-glass;
  • find your way among the common backends (s3, azurerm, gcs, consul, pg, http).

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap12/solution
./run.sh

Expected result

  • After the migration: state list ok, local terraform.tfstate at zero bytes, and the book-labs/cap12 key present in Consul's KV.
  • The colleague attached with no migration and his first plan said No changes, seeing your resources.
  • During your slow apply, his plan failed with Error acquiring the state lock and the name tag (ID, Operation, Who).
  • With your apply done, his plan worked again.
  • You answered the three questions in answers.md.
13The fire doorsIntermediate

What you build

A building without fire doors burns all at once. A project with a single state does too: in this exercise you first build the *monolith* — network and application in the same notebook — and measure the blast radius: the network team renames its own network, and the plan shows the fire spreading all the way to the app's container; meanwhile, a single lock queues everyone, whatever they are working on.

Then you install the fire doors: two configurations, two notebooks (in the Consul you know from chapter 12), and the official channel to make them talk — terraform_remote_state, the data source that reads another state's *outputs*. You close with the two containment proofs: the most destructive command in existence, launched in the app's room, cannot even see the network; and a slow app apply no longer blocks the network's plan — two queues, two locks, two teams genuinely working in parallel.

Objectives

  • explain the monolith problem: blast radius, single lock, ever slower plans;
  • recognise the classic cut lines (by component, by environment) and the criterion for choosing them;
  • make two states talk with terraform_remote_state, and say why the channel is the *outputs* (a contract, not free access);
  • demonstrate containment: destroy-scope limited to the room, independent locks;
  • pull Part 3's threads together: resources, data, state — who does what.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap13/solution
./run.sh

Expected result

  • In the monolith, renaming the network produced a plan with TWO replaces (network and container).
  • The app's plan showed the remote_state read (Read complete) and the network name already resolved.
  • tofu plan -destroy in the app room listed only container and image.
  • The network's plan passed (No changes) WHILE the app's slow apply was running.
  • You answered the three questions in answers.md.
14The three doorsIntermediate

What you build

So far you have written configurations with the values *hard-coded* inside: the container name, the port, all fixed in the file. That works for a single building. But the same project must serve dev, staging and prod — and rewriting the file for each is the photocopy chapter 1 taught us to fear.

This chapter installs three doors in the configuration. The front door — *variables* — lets values in from outside: whoever uses the module chooses environment and external_port without touching the code. The service door — *outputs* — shows the outside only what it promises: here, the URL where the service answers. And the internal kitchen — *locals* — has no door onto the world: it is where the container name is *derived* once (cap14-web-${var.environment}) and reused everywhere.

Objectives

  • declare input variables with type, description and default, and tell a *required* variable (no default) from an optional one;
  • put a bouncer on the input with a validation block (condition + error message);
  • pass a value from three sources — -var, TF_VAR_, terraform.tfvars — and predict which one wins when they conflict;
  • derive internal values with locals and explain why they are not variables;
  • expose a result with output, and say why the output is the only service door (chapter 13's contract is born here).

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap14/solution
./run.sh

Expected result

  • The plan with no environment stopped with *No value for required variable*.
  • -var environment=banana was rejected by validation with your message.
  • You had seen the same value enter from -var, TF_VAR_ and terraform.tfvars, and predicted who wins (-var, then file, then env).
  • apply printed url = "http://localhost:8095 (<env>)", and the page answered.
  • Changing environment produced a replace of the container (# forces replacement).
  • You answered the three questions in answers.md.
15The fleet: by number or by nameIntermediate

What you build

So far every resource was a one-off, written by hand. But a neighbourhood has a hundred identical houses, and nobody writes them one by one. This chapter gives you the two ways to *multiply* a resource — and shows you why the choice between them is one of the most important you will make.

The first way counts by number: count. You give a number, and get that many copies, indexed [0], [1], [2]. Handy, immediate — and with a hidden trap. Each copy's identity is its *position*: house number 2. Remove a house in the middle of the row, and every house after it *shifts down a number*: 3 becomes 2, 4 becomes 3. Terraform, which ties identity to position, thinks you renamed half the fleet — and rebuilds it. You will see it in your own plan: a single removal, and the fire spreads down the tail.

Objectives

  • multiply a resource with count and read its indexed addresses ([0], [1]…);
  • explain and *demonstrate* the fragile-index trap: why removing a middle element triggers a cascade of replacements;
  • multiply by identity with for_each, and say why it wants a set or a map (not a list) — hence toset();
  • make a resource exist or vanish with a conditional (? 1 : 0);
  • generate nested blocks with a dynamic block from a collection.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap15/solution
./run.sh

Expected result

  • With count, the addresses were web[0], [1], [2]; removing bravo produced a replace + a destroy (the cascade).
  • After TODO 1, the addresses were web["alpha"] etc.; removing bravo touched *only* bravo (0 add, 0 change, 1 destroy).
  • With TODO 2, canary_enabled=true produced 1 to add; false, no canary.
  • After TODO 3, docker inspect on cap15-alpha showed the team and tier labels.
  • You answered the three questions in answers.md.
16The workbenchIntermediate

What you build

The last chapter closed with a promise: the collections you hand to count and for_each often have to be *prepared* first — cleaned, transformed, filtered. This chapter gives you the tools to prepare them, and a bench to test the tools before you fit them.

The tools are the functions: HCL has about a hundred, ready-made (you cannot write your own in the classic way — you take what is there). They transform a string (lower, trimspace, split), count and combine collections (length, merge, keys), convert them (toset, tolist, jsonencode). A function takes input in parentheses and returns a value: nothing else, no side effects.

Objectives

  • say what an HCL function is and recognise the main families (string, collection, conversion, encoding);
  • use tofu console to try an expression without touching state or infrastructure;
  • transform a list with a for expression (list comprehension) and clean it with functions;
  • build a *map* with a for expression (map comprehension), and *filter* it with an if;
  • connect the transformed collection to a for_each (chapter 15's legacy) and see the dedup at work.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap16/solution
./run.sh

Expected result

  • On the bench (console): lower(trimspace(" Web-01 ")) gave "web-01" and split("-", "web-01")[0] gave "web".
  • After TODO 1, 4 raw hosts became 3 containers (dedup in the toset): the addresses were host["api-02"], ["db-03"], ["web-01"].
  • host_roles (TODO 2) mapped web-01=web, api-02=api, db-03=db.
  • web_hosts (TODO 3) held only web-01.
  • inventory.json held the JSON with hosts, roles and web.
  • You answered the three questions in answers.md.
17The prefabIntermediate

What you build

For sixteen chapters you have written the same pattern: variables at the top, resources in the middle, outputs at the bottom. And every time you rewrite it from scratch, folder after folder. A real architect does not redraw the same building for every lot: they design it once as a prefab, then drop it into the city as many times as needed, each with its own finishing.

The prefab, here, is the module: a folder with .tf files, but seen as a *box with doors*. The input doors are its variables (name, environment, port); the machinery inside is the resources (image and container); the output doors are its outputs (the URL where it answers). Whoever uses the box does not look inside: they pass inputs to the input doors, and read results from the output doors. They are exactly chapter 14's variables and outputs — but promoted to the *interface* of a reusable component.

Objectives

  • say what a module is and recognise its three parts (variables = input doors, resources = machinery, outputs = output doors);
  • write a local module and call it from the root with a module block (source + inputs);
  • instantiate the same module several times with for_each, each isolated, and aggregate their outputs;
  • explain why a module inherits the provider from the root, and when instead it must be passed explicitly (the aliased provider, 17.4);
  • recognise a remote module from the Registry (source + version) and why the version must be pinned.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap17/solution
./run.sh

Expected result

  • The module had no provider "docker" {} block: it inherited the provider from the root.
  • After TODO 2, tofu init printed "webapp in modules/webapp".
  • The state addresses had the module.webapp["blog"]/["shop"] prefix.
  • tofu output urls gave blog → 8101 (dev) and shop → 8102 (prod).
  • Removing shop from the map, the plan destroyed only the shop instance.
  • You answered the three questions in answers.md.
18The paperwork, not the buildingsAdvanced

What you build

Chapter 17 closed with a trap: by wrapping resources in a module you changed their *address*, and chapter 15 warned us the address *is* the identity. Rename a resource in the code, and Terraform does not see a new nameplate: it sees a resource gone and a new one born — it demolishes and rebuilds. For a container that is an annoyance; for a production database it is a disaster.

But chapter 11's notebook — the state — is only a *map* between addresses in the code and real objects. And a map can be corrected without touching the territory. This chapter gives you four ways to change the paperwork without touching the buildings:

Objectives

  • explain why renaming a resource naively causes destruction and recreation (the address is the identity);
  • rename safely with a moved block, and verify the resource was not touched;
  • stop managing a resource without destroying it with a removed block;
  • adopt an existing resource, created outside Terraform, with an import block;
  • use the tofu state commands (list, show, mv, rm) as a manual scalpel, and know when they are still needed.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap18/solution
./run.sh

Expected result

  • In Phase 0, the naive rename produced a plan with destroy + create.
  • With moved (TODO 1), the plan was 0 to add, 0 to change, 0 to destroy, and the container's ID stayed unchanged.
  • With removed (TODO 2), the cache disappeared from the state but the container stayed in the running state.
  • With import (TODO 3), the orphan volume entered the state (1 to import) and the next plan said No changes.
  • You used tofu state list/show and tried a state mv round trip.
  • You answered the three questions in answers.md.
19The drawer or the roomAdvanced

What you build

Dev, staging, prod: the same design, three different cities. Copy-pasting the configuration three times is the photocopy chapter 1 taught us to fear — but an environment is not just "the same code with a different name". An environment is an *isolated copy* of the same infrastructure, with its own settings and — above all — its own state. And chapter 13 already shouted it: environments must never share the blast radius. A mistake in dev must not be able to queue, corrupt or destroy prod.

This chapter compares the two main strategies, and puts both in your hands.

Objectives

  • say what an environment really is (different settings, separate state, non-shared radii);
  • use workspaces: terraform.workspace, workspace new/select/list, and understand their risk;
  • use separate directories with a shared module, and prove their isolation;
  • compare the two strategies on DRY versus isolation, and choose with your head;
  • recognise Terragrunt's role (DRY *on top of* separate directories).

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap19/solution
./run.sh

Expected result

  • With workspaces, terraform.workspace drove the name, and dev/prod had separate states in terraform.tfstate.d/.
  • workspace list showed default, dev, prod, with the asterisk on the current one.
  • With separate directories, dev and prod each had their own state and their own init.
  • Destroying dev, prod's container stayed alive and prod's plan said No changes.
  • You recognised, in the example, what Terragrunt generates (per-environment backend + common boilerplate).
  • You answered the three questions in answers.md.
20The twins and the lockCloud Architect

What you build

For nineteen chapters we have said "one language, two binaries": every tofu command has its terraform twin. This chapter keeps the promise and draws the boundary. Because the two binaries *are* twins — born from the same code — but from a certain point on they took different roads.

The history in brief (20.1): in 2023 HashiCorp changed Terraform's licence, from open source to a restrictive one (the BSL). The community reacted with a *fork* — a copy of the code that sets off on its own — placed under the Linux Foundation and renamed OpenTofu, with an open licence (MPL). From there, two twin binaries that share almost everything and diverge on a little.

Objectives

  • tell why OpenTofu exists (the 2023 licence change and the fork);
  • verify the 95%: the same configuration runs identically on tofu and terraform;
  • encrypt the state with OpenTofu's native encryption, keeping the passphrase *out* of the code (an environment variable);
  • prove the 5%: an encrypted state is unreadable without the passphrase, and unreadable at all for terraform;
  • choose between the two binaries with judgement.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap20/solution
./run.sh

Expected result

  • In Phase 0, bcrypt_hash appeared in plain text in the unencrypted state.
  • The same configuration ran identically with tofu and with terraform (Phase 1).
  • With TF_ENCRYPTION set, the state became an encrypted envelope: no secret in plain text.
  • Without the passphrase, tofu refused to read the state; terraform refused it entirely (Unsupported state file format).
  • You answered the three questions in answers.md.
21The pyramid of checksCloud Architect

What you build

An architect does not hand over a design because it "looks right": they run it through a ladder of checks, from the cheapest and most frequent to the most expensive and rare. It is the validation pyramid, and it has four floors.

At the *base*, wide and instant, two checks you run constantly: fmt verifies the drawing is legible (the form), validate that it is internally consistent (references resolve, types match). They cost milliseconds; you run them on every save.

Objectives

  • describe the validation pyramid and why it has that shape;
  • use fmt and validate as an instant safety net (the base);
  • recognise a policy-as-code rule and what it rejects (the middle floor);
  • write behaviour tests with tofu test: run, assert, expect_failures;
  • watch a test *reject* a regression — the reason tests exist.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap21/solution
./run.sh

Expected result

  • fmt -check and validate passed on the start configuration.
  • You recognised, in the policy example, which configuration would be rejected (an unpinned image) and why.
  • After TODO 1 and 2, tofu test gave Success! with all runs green, including the one with expect_failures.
  • Breaking the name in Phase 3, tofu test failed with Test assertion failed, and went green again once restored.
  • You answered the three questions in answers.md.
22The conveyor beltCloud Architect

What you build

For twenty-one chapters you ran the commands: plan, apply, test. This chapter — the last — takes them out of your hands and puts them on a conveyor belt. In one end goes a commit; out the other comes infrastructure in production. And along the belt, automatically, chapter 21's pyramid fires: form, consistency, security, behaviour. Nobody applies by hand; nobody forgets a check.

The belt has two stretches. The first is CI (Continuous Integration): on every *proposed* change — a pull request — the belt runs the checks and produces a *plan*, the broadcast of what would change. It is the gate: if a check fails, the door stays shut, and nobody argues with the machine. The second is CD (Continuous Delivery/Deployment): when the change is *approved and merged* into the main branch, the belt runs the apply. Proposing and delivering become two separate, automatic acts — plan on the PR, apply on the merge.

Objectives

  • tell CI from CD, and map "plan on the PR / apply on the merge" onto the two stretches;
  • read and complete a pipeline (GitHub Actions) that automates chapter 21's pyramid;
  • explain GitOps and *demonstrate* drift correction: reality pulled back to git;
  • say why OIDC replaces static credentials, and recognise its shape in the pipeline;
  • place the tools (the pipelines, a nod to Atlantis) in the picture.

How to test it

Fill in the TODOs in the starter files, then run the solution's test:

cd terraform-opentofu/ed1/cap22/solution
./run.sh

Expected result

  • You completed, in pipeline.yml.example, the four steps of the plan job (TODO 1) and the guard of the deploy job (TODO 2).
  • Locally, the fmt/init/validate/plan sequence passed, and apply delivered the container on 8140.
  • Deleting the container by hand, tofu plan said 1 to add, and apply pulled it back (drift correction).
  • You recognised, in the pipeline, permissions: id-token and the role assumed via OIDC (no static key).
  • You answered the three questions in answers.md.