HCL replace_triggered_by and terraform_data — the replace that is not a diff
The trigger list is a list of graph addresses. Variables are not in it. An empty plan is not a rotation.
<!-- hal:authoritative:yaml -->
§I — Frame
The 07-25 HCL lesson taught three constructs that change how many things exist: count, for_each, and dynamic. The 08-03 lesson typed the contract those things sit behind. The 08-15 lesson built the object a for expression writes. The 08-24 lesson asked whether the nest those constructs stamp is even legal. All four assumed that when a resource must be rebuilt, some argument of that resource had moved.
Today the leftover is the rebuild with no moved argument.
Lab 32's README is the exam sentence: force safe replacement when upstream dependency changes are not automatically represented as direct argument drift. The starter already names the type. Two terraform_data resources. A marker that holds a version. A service that holds name, version, and owner. A lifecycle block that ignores owner, creates before destroy, and replaces when the marker moves.
Call the failure the replace that is not a diff. An author who knows count and does not know the trigger list writes a pretty module whose plan stays empty after a rotation. The compiler of 07-25 is not enough. The types of 08-03 are not enough. The constructor of 08-15 is not enough. The schema of 08-24 is not enough. Core has to be told about an edge the arguments do not draw.
Brikman left Zero-Downtime Deployment on the shelf in Chapter 5 (pp.271-275). 08-12 spent it on create_before_destroy and an ASG. The 3ed does not name replace_triggered_by. pdfgrep is empty. That is not a gap. Lab 32 is the corpus. terraform_data post-dates the book (1.4). Lab 32's versions.tf requires >= 1.6. Stay in that room.
Ops wrote the Azure shirt: a versionless Key Vault URI on azurerm_linux_web_app.app_settings, a secret version that can move while that URI sits still, an empty plan as the finding. This lesson writes the HCL that makes the next plan a replace. The Azure types stay in the comments. The grammar is Core.
§II — Language Idiom: four surfaces at the trigger
**Surface one. replace_triggered_by is a list of managed addresses. It is not a boolean.**
lifecycle {
replace_triggered_by = [terraform_data.release_marker]
}
The list holds resource addresses, or resource.attribute addresses. Each entry is something Core can watch for a replace of its own. When that something is replaced or has the named attribute change, this resource is replaced, even if none of its own arguments moved.
A boolean does not belong here. replace_triggered_by = [var.rotate] will not compile as a trigger, or will not do what the author meant, because a variable is not a managed object. replace_triggered_by = [local.secret_version] is the same miss. Locals are named expressions. They are not in the graph as nodes that get replaced. Put the version on a resource. Point the list at that resource.
Ops already stated the failure mode. The HCL is the reason: the lifecycle argument is typed as a list of references into the graph, not as a list of values. 08-03's type contract lives on variable blocks. This contract lives on the graph.
Surface two. Whole resource versus one attribute is a real choice.
Lab 32 points at the whole marker:
replace_triggered_by = [terraform_data.release_marker]
Any change to the marker, including a change you did not care about, rebuilds the service. Pinning one attribute is tighter:
replace_triggered_by = [terraform_data.release_marker.output]
or, on the Azure shirt Ops wrote:
replace_triggered_by = [azurerm_key_vault_secret.app.version]
version moving rebuilds the app. A tag on the secret does not. The whole-resource form is what the starter ships. The attribute form is what you write when the upstream has more than one reason to change and only one of those reasons should kill the worker. Name the choice. Do not treat the two addresses as synonyms on the exam.
terraform_data.output is computed after apply. terraform_data.input is what you wrote. Triggering on output waits for the apply of the marker. Triggering on the resource waits for the marker's planned replace. Lab 32 uses the resource. Keep that unless you can say why the output is the signal.
**Surface three. terraform_data is the signal object when you do not want a cloud object to be the signal.**
resource "terraform_data" "release_marker" {
input = {
version = var.release_version
}
}
resource "terraform_data" "service" {
input = {
name = var.service_name
version = var.release_version
owner = var.external_owner
}
lifecycle {
create_before_destroy = true
ignore_changes = [input["owner"]]
replace_triggered_by = [terraform_data.release_marker]
}
}
This is Lab 32's main.tf with no omitted line. versions.tf still declares an aws provider the resources never use. That is a leftover of the lab family's AWS default, not a requirement of terraform_data. Core owns terraform_data. No plugin schema answers it. 08-24's "nest the provider still has to answer" does not apply here. The nest is Core's.
Why two objects, when service.input.version already holds the version? Because ignore_changes on owner means a portal edit of owner must not rebuild the service, and because a version change that also sits in service.input is argument drift on the service and a trigger via the marker. Lab 32 wires both on purpose. The isolation the README asked for is the case where the service's remaining arguments would not move. On Ops, that case is the versionless URI. On the lab, you can make it by taking version out of service.input and leaving it only on the marker. Then a version bump is not a service diff. It is only a marker replace plus the trigger. That edit is the leftover the starter does not make for you.
null_resource is the old name. Do not write it on a 1.6 lab. terraform_data replaced it. Provisioners are not today's fire. input is the argument. output mirrors input after create. Triggers-as-map was null_resource. Do not mix the two vocabularies on one stem.
Surface four. The other two lifecycle arguments in the starter are neighbors, not the topic.
create_before_destroy = true is Lab 22. 08-12 spent it. Two generations, unique names, name_prefix first. Leave the ASG. The flag may sit next to the trigger so a replace does not drop the only copy. That is adjacency.
ignore_changes = [input["owner"]] is Lab 23's shape. A value that drifts outside Terraform is not a reason to rebuild. Owner is that value. Version is not. The split is the lesson: one attribute ignored, one edge declared. ignore_changes = all would also ignore input.version if you had left version on the service, and you would then depend entirely on the trigger. Scoped ignore plus an explicit trigger is the starter's actual design. Copy both lines. Do not "simplify" into all.
prevent_destroy is Lab 01. It is not in Lab 32's starter. Do not add it to make the lab look complete. A resource you have marked to replace cannot also refuse destroy in the same apply without a fight. The exam will offer that fight. Decline it.
Four mistakes, named so you can see them on a stem. First, the list points at a variable. Second, the list points at a local. Third, version stays on service.input and the author thinks the trigger is the only reason the service will move; argument drift is still there, and the isolation is dirty. Fourth, ignore_changes = all swallows the one argument that would have been a real diff, so the author now depends on a trigger they also pointed at the wrong address. Any one of the four returns an empty plan after a rotation. The empty plan is the finding, the same way Ops treats it on the Web App.
§III — Code Worked Example
Lab 32's test file is one plan run:
run "lifecycle_meta_arguments_are_present" {
command = plan
assert {
condition = output.service_state.value.name != null
error_message = "Expected service_state.name to be present."
}
assert {
condition = output.service_state.value.version != null
error_message = "Expected service_state.version to be present."
}
}
Success mode is plan. The asserts check that the service output still has name and version. They do not, by themselves, prove a replace on a version bump. The lab's broken starting point already has the three lifecycle lines. The correction is not "add the block." The correction is to make the trigger the thing that actually fires when version moves and owner does not.
Work it as two plans in your head, even though the shipped test is one run.
Plan A. Change external_owner. ignore_changes on input["owner"] means the service does not want an update for that field. The marker did not move. replace_triggered_by is quiet. Plan should not replace terraform_data.service. If it does, the ignore is missing or the trigger is pointed at something owner also touches.
Plan B. Change release_version. The marker's input.version changes. The marker wants replacement. The service wants replacement because of the trigger. If you also left version inside service.input, the service additionally wants an update-or-replace from its own argument. The clean isolation is Plan B with version removed from service.input. Then the only reason the service replaces is the list.
A third plan, the trap. Change release_version and write:
replace_triggered_by = [var.release_version]
That address is not a managed resource. Fix the list. Point it at terraform_data.release_marker or at terraform_data.release_marker.input if you mean the input map. Do not point it at the variable the marker already consumed. The variable is how the marker learns. The marker is what the service watches.
Ops's Azure refraction, kept as a comment on the same grammar:
resource "terraform_data" "release_marker" {
input = {
version = azurerm_key_vault_secret.app.version
}
}
resource "azurerm_linux_web_app" "app" {
# settings string uses versionless_id and does not move
lifecycle {
replace_triggered_by = [terraform_data.release_marker]
}
}
The Web App is Lab 32's service wearing an Azure type. The marker still has no Azure type. That is legal. Core objects can trigger plugin objects. Plugin objects can trigger Core objects. The list does not care which plugin answered the nest. 08-24's permission question is about site_config and identity, not about this list.
output "service_state" { value = terraform_data.service.output } is the lab's read. After apply, output equals input as written at create. A later ignore of owner means state may disagree with the file on that field, and that is the point of the ignore. Do not assert file-equals-state on an ignored attribute. 08-21 already taught an output that was still last apply. This output is current for name and version, stale-by-design for owner. Read the ignore list before you trust the output.
§IV — Connection to Today's Ops Lesson
Ops wrote the versionless URI and the two describes (terraform plan, az keyvault secret show). Dev writes the list that makes the first describe stop being empty after a rotation.
If Dev only restates App Service, it has skipped Lab 32. If Ops only restates lifecycle {}, it has skipped the URI. The hinge is the address. Marker is a managed object holding the version. Service or Web App names that marker in replace_triggered_by. Owner is ignored. Version is not interpolated into the settings string. Plan B is a replace. Plan A is not.
Cert will stay on terraform_data and terraform test with command = plan. That is the cheap room. Dev is the grammar of the list. Ops is the Azure objects the list is for. Do not make Dev apply an App Service to prove a reference compiles. terraform validate plus the two mental plans are the proof.
The refraction is one way. Ops cannot compile a trigger by naming Key Vault in prose. Dev cannot prove a rotation by naming azurerm_linux_web_app and then interpolating .id (the versioned form) into app_settings. That interpolation is a diff. Today's coin requires the versionless form plus the list. If your worked example uses .id and then adds replace_triggered_by anyway, you have two reasons for the app to move and you will not know which one the plan used.
§V — Prior-Lesson Reach
07-25 taught dynamic and count and for_each. Those change how many copies exist. replace_triggered_by changes whether the one copy you already have is rebuilt. Do not count = 0 the Web App to "rotate" it. That deletes the guest. 08-30 Cert already used that trap on an optional bucket.
08-03 taught optional() and validation. A validation block cannot see another resource's version. replace_triggered_by can. Do not write a precondition that reads azurerm_key_vault_secret.app.version and expect that to rebuild the app. A failing precondition stops the plan. It does not replace. Different instrument.
08-15 taught { for k, v in x : k => ... }. You do not construct the trigger list with a for-expression unless you have a for_each of upstreams and you know why. Lab 32's list has one address. Write the one address.
08-24 taught configuration_aliases and schema-permission. lifecycle is not in the azurerm schema. Core injects it. A child module that wants a trigger still writes the block on the resource, not in required_providers. Do not invent configuration_aliases = [lifecycle.primary]. That is a category error.
08-12's terratest fire watched an apply. Today's Dev slot is HCL, not Go. Leave CopyTerraformFolderToTemp. Leave HTTP polling. The pair-family voice is 09-01 and 08-30. The HTML family is 08-30 and 08-24. The language home is Polyglot-Dev/HCL/.
§VI — Closing
The trigger list is a list of graph addresses. Variables are not in it. Locals are not in it. A boolean is not in it. terraform_data is in it when you need a signal that is not a cloud object. The whole resource is a wider trigger than one attribute. ignore_changes on owner is how you refuse a rebuild you do not want. replace_triggered_by on the marker is how you request a rebuild the arguments will not request.
Name it when you see it. The replace that is not a diff. Write the marker. Point the list at the marker. Take the version off the service input if you want the isolation clean. Leave owner ignored. Validate. Plan. Watch the replace appear only on Plan B.
Examine well. Pretty HCL is not a trigger. An empty plan is not a rotation. The plugin will not invent the edge. Core will, if you name it.
Related
- Prior arc: HCL configuration_aliases, the nest the provider still has to answer (2026-08-24)
- Domain hub: Cross-References/domains/01-Earth-DevOps
- Grounding tome: Lab 32 — Replace Triggered By (Brikman Ch.5 pp.271-275 referenced as 08-12 sibling; no replace_triggered_by in the 3ed)