Terraform Azure App Service and Key Vault — the replace that is not a diff
A versionless Key Vault reference does not move when the secret does. The worker still holds what it fetched at start. Plan is empty.
<!-- hal:authoritative:yaml -->
A versionless Key Vault reference does not move when the secret does. The worker still holds what it fetched at start. Plan is empty. That emptiness is the outage.
§I — Frame
Tuesday named a scan. Coin: the scan that is not the table. Sunday named a subnet latch. Coin: the access that is not a public IP. Thursday named a visa. Coin: the role that is not a key. None of those leftovers is today's.
Today the track is Terraform. The last four TF-and-adjacent Ops shirts were GCP BigQuery, GKE COS, GCP PGA, AWS STS. Three Google, one AWS. Azure is the rebalance. The azurecheatsheet Compute row names App Services. The Security row names Key Vault. Those two objects, joined, are the referent. 08-21 already used Azure Storage public_network_access_enabled. 08-23 already used Key Vault as a Python properties census and refused get_secret. Today is a Linux Web App whose app_settings holds a versionless Key Vault reference, and a secret whose version can rotate without that string changing.
08-12 walked the lifecycle block as a survey: four arguments, AWS launch template, ASG behind an ALB, Brikman Ch.5 pp.271-275, Labs 22 and 23 and 32 named in one breath. Today's job is the one argument that survey could only point at. Lab 32's README is one clause: force safe replacement when upstream changes are not direct argument drift. The plan has no diff. The runtime still needs a new object.
Name the duty. Coin it: the replace that is not a diff.
A diff is an argument that moved. A replace is a destroy plus a create. They are allowed to travel together. When they do not, Terraform plans nothing, App Service keeps the worker that fetched the previous secret, and the rotation you performed in Key Vault has not happened for the app. The author who reads an empty plan as "already correct" has misread the instrument.
This is not 08-12. That lesson isolated four arguments on one AWS shirt and spent create_before_destroy as the zero-downtime default you have to earn with name_prefix. Today's flag is replace_triggered_by. The cloud is Azure. The object that moves is a secret version, not an AMI.
This is not 08-21. Storage public_network_access_enabled was a grant on an account. Today's join is App Service plus Key Vault.
This is not 08-23. That Python fire asked Key Vault for properties and refused the value. Today's question is whether Terraform will replace the app when the version changes and the settings string does not.
This is not 08-30. PGA absence was a public IP you did not mint. Absence here is a plan with no actions, which looks like success.
§II — Foundations: four facts about the missing diff
Fact one. App Service and Key Vault are two objects. The reference is a third.
The azurecheatsheet keeps them on different rows on purpose. App Services host the app. Key Vault holds the secret. Neither row names the join. The join in Terraform is a string you put in app_settings:
resource "azurerm_key_vault_secret" "app" {
name = "connection-string"
value = var.db_connection
key_vault_id = azurerm_key_vault.app.id
}
resource "azurerm_linux_web_app" "app" {
name = "app"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
service_plan_id = azurerm_service_plan.app.id
identity {
type = "SystemAssigned"
}
app_settings = {
DATABASE = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.app.versionless_id})"
}
}
versionless_id is the URI without a version guid. App Service is told "fetch whatever is current." That fetch happens when the worker starts. A later rotation in Key Vault writes a new version. The URI in app_settings is the same URI. Terraform compares the string to state. The string matches. Plan is empty.
If/then: if the settings string embeds azurerm_key_vault_secret.app.id and that id carries the version, a rotation is an in-place update of app_settings. The plugin may recycle the worker. That is a diff. Today's coin does not live there. If the settings string is versionless, a rotation is not an argument. Plan stays empty. The worker keeps what it fetched at start. That is the coin.
Fact two. Lab 32 already authored the two-object split. Azure is the shirt. The split is the skill.
Lab 32's starter is two terraform_data resources. release_marker holds var.release_version. service holds name, version, and owner. The service lifecycle block already writes three arguments:
lifecycle {
create_before_destroy = true
ignore_changes = [input["owner"]]
replace_triggered_by = [terraform_data.release_marker]
}
create_before_destroy is Lab 22. 08-12 spent it. Do not reopen Brikman's ASG naming collision. ignore_changes on owner is Lab 23's shape sitting next to today's trigger: a tag that may drift outside Terraform must not itself cause a replace. replace_triggered_by is the line Lab 32 is testing. The marker is the upstream. The service is the object that must be rebuilt when the marker moves and the service's own arguments do not.
Map it onto Azure without importing AWS. The marker is azurerm_key_vault_secret.app.version. The service is the Web App. Owner is a tag a human edited in the portal. Ignore the tag. Trigger on the version. The lab's terraform_data objects remain legal stand-ins when you do not want to pay for an App Service in a plan-only drill. Cert will stay in that room. Ops has to name the Azure objects the cheatsheet already named, because the overlay is the point of the fire.
Fact three. An empty plan is a verdict, not a compliment.
terraform plan
az webapp show --name app --resource-group app --query outboundIpAddresses
az keyvault secret show --vault-name app --name connection-string --query id
The first call is the instrument. No actions is the finding when you know a version rotated. The second call is the app. The third call is the secret id, version guid included. If the plan is empty and the secret id's trailing guid is new, the join did not happen. An output that prints the Web App name has not made that join. 08-30's two describes (subnet flag, NIC accessConfigs) were the same habit on a different cloud. Today's two describes are the Key Vault secret id and the empty plan.
Do not az webapp restart from the census. A restart is a write. The census that starts recycling workers is no longer a census. The replace belongs in the next apply, wired through replace_triggered_by, so the graph owns it.
Fact four. The trigger address is a managed resource or a resource attribute. It is not a variable.
Lab 32's trigger is [terraform_data.release_marker], the whole resource. A change to any attribute of the marker forces the service to replace. You may also write [azurerm_key_vault_secret.app.version] and trigger on one attribute. You may not write [var.release_version]. You may not write [local.secret_version]. Variables and locals are not in the graph as objects that can be replaced. The HCL fire will drill the grammar. Ops only needs the failure mode: an author who puts the version in a variable, interpolates it nowhere on the Web App, and expects replace_triggered_by = [var.release_version] to save them will not compile, or will compile a no-op, depending on the version of the language. Put the version on a managed object. Point the lifecycle block at that object.
Four facts, one coin. The settings string can stay still. The secret version can move. Plan can be empty. Empty is the bug when you meant a rotation.
§III — Mechanism: the trigger, not the restart CLI
Wire a marker. Keep the Web App's app_settings versionless. Point replace_triggered_by at the marker.
resource "terraform_data" "release_marker" {
input = {
version = azurerm_key_vault_secret.app.version
}
}
resource "azurerm_linux_web_app" "app" {
name = "app"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
service_plan_id = azurerm_service_plan.app.id
identity {
type = "SystemAssigned"
}
app_settings = {
DATABASE = "@Microsoft.KeyVault(SecretUri=${azurerm_key_vault_secret.app.versionless_id})"
}
lifecycle {
replace_triggered_by = [terraform_data.release_marker]
}
}
When the secret version changes, the marker's input changes, the marker is replaced, and the Web App is replaced because the lifecycle block said so. The settings string never moved. The replace is not a diff of app_settings. The replace is the trigger.
create_before_destroy may sit next to the trigger if two generations of the app must overlap. That overlap is Lab 22 and 08-12. Name it so you do not pretend today's fire invented zero-downtime. Then leave it. A slot-name collision on App Service is a different outage (two apps cannot share name). If you need overlap, you need a name strategy first, the same order 08-12 taught on name_prefix. Today's overlay does not redo that order.
ignore_changes = [tags["owner"]] may sit next to the trigger if a human edits a tag in the portal. That ignore is Lab 23. Lab 32 already carries it on input["owner"]. The point of carrying it beside replace_triggered_by is the split: some drift is not yours, and must not rebuild the app; some drift is yours, lives on another resource, and must. Owner is the first. Secret version is the second.
Do not put azurerm_key_vault_secret.app.value in app_settings. That writes the secret into state on the Web App. 08-09 already taught that the state file is a secret. The versionless reference exists so the value stays in Key Vault. Today's fire uses that posture and asks a further question the 08-09 lesson did not: once the value is correctly elsewhere, how does the app notice it moved?
Do not terraform apply -replace=azurerm_linux_web_app.app as the standing procedure. -replace is a one-run override. The standing procedure is the lifecycle block. Cert will put both on the same stem. Ops writes the block.
The identity block is SystemAssigned so the app can get the secret. Grant Key Vault Secrets User after the app exists. That grant is not today's coin. A 403 is IAM. An empty plan after a later rotation is this lesson.
§IV — Worked Example
One vault, one secret, one Linux Web App, one marker. Resource group app in eastus. The secret starts at version n. The app's DATABASE setting is the versionless URI. First apply creates all four. terraform plan after that apply is empty. Good.
A pipeline writes a new secret value. Key Vault mints version n+1. A human who only watches the vault will say the rotation happened. Run plan against the same root. Without the lifecycle block, the plan is still empty, because versionless_id did not change and no other argument did. The worker is still the worker from the first apply. It fetched version n at start. It will hold n until it is replaced or restarted. The rotation happened in the vault. It did not happen for the app.
Add the marker and the replace_triggered_by line. Plan again. The marker wants replacement. The Web App wants replacement because the marker did. Apply. New worker. Fetch of n+1 at start. That is the coin spent well.
A portal user then edits the owner tag. Lab 32 ignores owner so that edit is not a trigger. Keep the ignore scoped. ignore_changes = all would also swallow a versioned settings string on a day you did have a diff. 08-12 named never-all. It still holds.
The CLI Evan types when the join is one app and one secret:
terraform plan
az keyvault secret show --vault-name app --name connection-string --query id
az webapp config appsettings list --name app --resource-group app --query "[?name=='DATABASE'].value"
Plan first. Secret id second (version guid at the end). Settings value third (must stay the versionless URI). If plan is empty and the guid moved, the trigger is missing. If the settings value contains the guid, you are no longer on today's coin; you are on an in-place update, and that is a different lesson.
Do not az webapp restart. Do not az keyvault secret set from the census. The census reports. The next apply replaces.
§V — Connection to Prior Lessons
08-12 taught the four-argument survey on AWS. create_before_destroy, ignore_changes, replace_triggered_by, prevent_destroy. Brikman Ch.5 pp.271-275 is that fire's Zero-Downtime spend. Today's fire takes the one argument of the four whose job is a replace the diff cannot see, moves the shirt to Azure App Service plus Key Vault, and refuses to reopen ASG min_elb_capacity or name_prefix. If a paragraph here starts explaining target-group health, it has drifted back to 08-12.
08-30 taught absence as a public IP you did not mint. Today's absence is an empty plan. Same habit, two describes, different objects. Leave PGA's access_config off this page.
08-27 taught assume_role as a visa. Today's Key Vault reference also keeps the value out of the app settings. The new question is the replace after that value moves.
08-24 taught a nest the google plugin had to answer. replace_triggered_by is a nest Core answers, on every resource type. The 08-24 permission question still holds for app_settings. It does not hold for the trigger list.
08-21 taught Azure Storage public access and an output that was still last apply. Leave the storage account. Today's liar is an empty plan.
08-18 taught the lock you did not hold. Leave DynamoDB. 08-15 taught a computed GSA/KSA binding. Leave GKE. Count 0 on the Web App would delete the guest. Do not hide a rotation by destroying it.
§VI — Connection to Today's Dev Lesson
The Dev slot is HCL. tf_day_dev_counter reads 12, 12 mod 3 is 0. Fifth HCL-depth fire. 07-25 taught count, for_each, dynamic. 08-03 taught the type contract. 08-15 taught the for-expression constructor. 08-24 taught configuration_aliases and the schema-permission for a nest. None of those four grounded a replace the resource's own arguments do not request.
This Ops lesson is the Azure objects that fire will keep in the comments and drop from the grammar drill. If the Dev lesson only restates App Service vocabulary, it has skipped Lab 32's terraform_data pair. If this Ops lesson only restates lifecycle {}, it has skipped the versionless URI. The hinge is one sentence. Key Vault mints a new version. app_settings does not move. HCL names the marker and the trigger list so the next plan is a replace, not a no-op.
Cert will stay in Lab 32's AWS-cost-none room (terraform_data only, success mode plan). Ops named the Azure shirt so the overlay is real. Dev names the address grammar so the trigger compiles. Three altitudes, one coin.
§VII — Closing
A versionless Key Vault reference is a fetch-at-start, not a subscription. A secret version can move while that string sits still. Terraform will not replace the app unless you name the missing edge. replace_triggered_by is that edge. The marker is a managed object. The plan that stays empty after a rotation is the finding.
Name it when you see it. The replace that is not a diff. Write the versionless URI. Write the marker. Point the lifecycle block at the marker. Ignore the portal tag. Believe the empty plan the way 08-30 believed an empty accessConfigs list: as a fact, not as a comfort.
Examine well. The App Service name will still be pretty. The latch is the version. The door is the trigger. The worker is the one who still holds last week's string until you replace it.
Related
- Prior arc: GCP Private Google Access, the access that is not a public IP (2026-08-30)
- Domain hub: Cross-References/domains/01-Earth-DevOps
- Grounding tome: Lab 32 — Replace Triggered By (Lab 22/23 referenced as 08-12 spends; Brikman Ch.5 pp.271-275 referenced as the sibling, not the claim)