Business Challenge
Posts #24 to #26 covered Microsoft's own tooling: where a deployment runs, what a template contains, and what Bicep compiles to. This post covers the tool a large share of Azure estates actually use, and the honest way to introduce it is by naming the structural difference rather than comparing syntax.
An ARM or Bicep deployment involves two things: the template you submit, and what Azure currently holds. Resource Manager reconciles them. There is no third artefact, nothing to store, nothing to lock, and nothing to lose.
Terraform involves three: the configuration you wrote, the state it maintains, and what Azure currently holds. State is not an optimisation that could be removed — HashiCorp's own documentation is blunt that state is a necessary requirement for Terraform to function — and it does four jobs at once:
- Mapping. Terraform requires state to create a mapping to map Terraform config to the real world — which real Azure resource a given block refers to.
- Dependency metadata. State maintains metadata such as resource dependencies, which matters most when destroying: the configuration describing the relationship has been deleted, so the record is the only thing that knows the order.
- An attribute cache. State functions as a cache of the attribute values for all resources, so a plan does not have to read the whole estate back through the API.
- Team synchronisation. For collaborative work, remote state is the recommended solution — one shared snapshot with locking rather than one per laptop.
Everything distinctive about running Terraform on Azure, good and bad, comes from that third artefact. The plan preview that Bicep has no equivalent of exists because Terraform has a record to diff against. So does drift. So does the state file being the most sensitive object in the pipeline.
If Resource Manager is the only record, there is nothing for reality to drift from — a Bicep deployment simply asserts the desired shape again. Terraform's state can disagree with Azure, and reconciling that disagreement is a recurring operational task rather than an occasional accident. That is the cost side of the ledger, and it is worth paying only because the plan preview and the multi-cloud surface are on the other side.
Architecture
Where state should live, and why local is the wrong answer
By default Terraform state is stored locally, and Microsoft lists three reasons that is not acceptable for anything real: local state does not work well in a team or collaborative environment, Terraform state can include sensitive information, and storing state locally increases the chance of inadvertent deletion.
The Azure answer is the azurerm backend, which puts state in a blob. The configuration needs four things: the storage account name, the container name, the key — which is the name of the state file to create — and credentials. Two behaviours come with it for free, and both matter:
- Locking. Azure Storage blobs are automatically locked before any operation that writes state. That is what prevents two concurrent runs corrupting it, and it is visible as a lease on the blob in the portal.
- Encryption, and nothing on disk. Data in a blob is encrypted before being persisted, and Terraform retrieves state into local memory rather than writing it out — so state is never written to your local disk. The laptop stops being a place state can leak from.
There is a bootstrap problem worth planning for rather than discovering: before you use Azure Storage as a backend, you must create a storage account. Whatever creates it cannot itself be using it, so the backend storage is either created by CLI, or by a small Terraform configuration whose own state is local and disposable. Either way it is a deliberate first step, not part of the main pipeline.
Authentication: the part the Microsoft tutorial explicitly defers on
The Microsoft walkthrough authenticates the backend with a storage access key, held in the ARM_ACCESS_KEY environment variable so it is not written to disk, and it says plainly what that is: a tutorial choice. In a production deployment, evaluate the available authentication options supported by the azurerm backend and use the most secure option for your use case.
HashiCorp's backend documentation answers that. It marks SAS token, access key and access key lookup as not recommended for new workloads, and Microsoft Entra ID as the recommended path, enabled with use_azuread_auth = true — described as using Microsoft Entra ID authentication to the storage account data plane.
That word is the connection to post #19. The state blob is a data plane object, so the role required is Storage Blob Data Contributor on the container — and no control plane role implies it. A pipeline identity holding Contributor on the subscription can see the storage account and cannot read the state file, which is exactly the separation post #18 described and exactly the error that gets misdiagnosed as a backend misconfiguration.
Terraform state is stored in plain text and might contain secrets. If you don't secure the state correctly, unauthorized users can access systems and cause data loss. Post #25 covered the trouble Microsoft goes to keeping secrets out of ARM deployment history — a securestring output is unreadable even by another template. Terraform's model puts resource attributes in a file so that it can diff them, and that file is plain text. Whatever an ARM template would have hidden, the state records. Which is why the storage account holding it deserves a storage firewall, service endpoint or private endpoint, and access controlled as tightly as anything it describes.
Drift, and the failure Microsoft chose to document
The clearest example of the two-records problem is one Microsoft puts on the same page as the backend setup, and it is worth reading closely because it is not a corner case.
Azure Storage account conversions can change storage account properties outside of Terraform. If Terraform detects those changes before the configuration is updated, it might plan to delete and recreate the storage account — which, in Microsoft's own words, can cause application downtime or data loss.
Sit with the shape of that. Somebody makes a legitimate change through the portal or a support process. Terraform notices, decides the real resource no longer matches, and concludes the correct remedy is to destroy and rebuild it. Nothing malfunctioned; the tool did exactly what its model requires. The danger is entirely in a plan that looks routine being approved without being read.
The documented procedure is a good template for handling any out-of-band change:
- Disable automatic approval and review every
terraform planfor unexpected resource replacement. - Set
prevent_destroy = truein the resource'slifecycleblock, so an accidental destroy fails rather than proceeds. - Temporarily
ignore_changeson the property being converted, so Terraform stops trying to reconcile it mid-flight. - Reconcile afterwards: run
terraform apply -refresh-only, update the configuration to match the converted values, removeignore_changes, then runterraform planand confirm it reports no unexpected changes.
Note the ordering. The guard rails go up before the out-of-band change, and come down only after a clean plan proves the three records agree again.
Why This Architecture Holds Up
State is a shared, lockable, single point of failure
Everything about how state is stored is an availability and blast-radius decision. One state file per environment means one lock: two pipelines cannot apply at once, which is protective and also a queue. Splitting state into more files removes the queue and removes the guarantee that a single plan sees the whole picture.
That trade is the real design work in a Terraform estate, and it maps onto the same lifecycle question posts #21 and #23 kept arriving at: things that change together belong together. A state file per lifecycle boundary — platform networking, shared services, each workload — gives independent locks and independent blast radius, at the cost of needing explicit plumbing to pass values between them.
The plan is the feature, so protect the habit of reading it
The plan preview is the strongest argument for Terraform on Azure, and the storage-account example shows it is also the only thing standing between an out-of-band change and a deleted resource. Automatic approval removes exactly the safeguard that justifies the tool.
This is the same argument post #17 made about policy effects and post #23 made about resource moves: put the cost in front of the person who can act on it. A plan nobody reads is a validation step that has been switched off while still appearing in the pipeline.
Identity for the backend is a separate decision from identity for the provider
Two authentications happen in a Terraform run and they are easy to conflate. The provider authenticates to Resource Manager to create resources — a control plane concern, and the place subscription-scoped roles apply. The backend authenticates to the storage account to read and write state — a data plane concern, needing Storage Blob Data Contributor on the container.
Getting one right does not get the other right, and the recommendation for both is the same in spirit: Entra ID over keys, and credentials supplied through environment variables rather than committed to configuration. Microsoft adds the belt-and-braces version for anyone still using an access key — store it in Key Vault and read it into the environment variable at run time.
Key Architecture Decisions
The shape that works
| Decision | What to do | Why |
|---|---|---|
| Where state lives | The azurerm backend, never local, from the first commit |
Local state fails in a team, can include sensitive information, and is easy to delete by accident. |
| Backend authentication | use_azuread_auth = true with Storage Blob Data Contributor on the container |
Access keys and SAS tokens are marked not recommended for new workloads, and the state blob is a data plane object no control plane role reaches. |
| Protecting the state account | Storage firewall, service endpoint or private endpoint, plus access reviewed like the estate it describes | State is plain text and might contain secrets, so the account holding it is as sensitive as everything in it. |
| Bootstrapping the backend | Create the storage account deliberately, by CLI or a disposable local-state configuration | The backend must exist before it can be used, so the chicken-and-egg is planned rather than discovered. |
| State granularity | One state file per lifecycle boundary | Each file is a lock and a blast radius. One file for everything serialises every team; one per resource loses the whole-picture plan. |
| Automatic approval | Off for anything holding data | A routine-looking plan can contain a delete-and-recreate after an out-of-band change, which is downtime or data loss. |
| Irreplaceable resources | prevent_destroy = true in the lifecycle block |
It converts a catastrophic plan into a failed one, which is the right direction for that error. |
| After any out-of-band change | terraform apply -refresh-only, update configuration, then a clean plan |
The documented reconciliation, and the only evidence that the three records agree again. |
| Credentials anywhere | Environment variables, and Key Vault behind them if a key is unavoidable | The documented recommendation, and it keeps secrets off disk and out of the repository. |
Closing Thought
It would be easy to read this as an argument against Terraform on Azure, and it is not. The state file buys something Microsoft's own tooling does not offer: a preview that can say this apply will destroy that resource before anything happens. That is a genuine safety property, and it exists precisely because Terraform keeps its own record to diff against.
What the record also buys is an obligation. A second source of truth has to be stored somewhere durable, locked against concurrent writes, encrypted, access-controlled as tightly as the estate it describes, and reconciled whenever the world changes underneath it. None of that is optional, and none of it exists in a Bicep pipeline — not because Bicep is better, but because it never took on the obligation in the first place.
So the choice is not really about syntax or ecosystem. It is whether the plan preview and the multi-cloud surface are worth operating a second record of what exists. For most estates running more than one cloud the answer is yes. For an Azure-only estate that has never wanted a plan, the honest answer is that Bicep asks less of you — and asking less is a real feature when the thing being asked for is custody of a plain-text file containing your secrets.
#28 returns to Microsoft's answer to the same problem: Deployment Stacks and managed resources — the construct from post #21 seen as a lifecycle tool rather than a protection mechanism, and how its record of what it manages compares to the one Terraform keeps.
Comments