Someone clicked a resource group into existence two years ago. Then a VNet, a storage account, an App Service, a SQL database — all in the portal, under deadline, with no template behind any of them. Today it runs production, and you have been told to “put it in Terraform” without taking it down. This is brownfield adoption: bringing resources that already exist and already serve traffic under Infrastructure-as-Code control, as opposed to the greenfield case where Terraform creates everything from nothing. The hard part is not writing the HCL — it is making Terraform’s state file agree, byte for byte, with what Azure already has, so that the very first terraform plan after import says “No changes” and not “destroy and recreate your production database.”
The mechanism is import: you tell Terraform “this resource block in my code corresponds to that resource ID already in Azure,” and it records the mapping in state without touching the live resource. Get it right and the resource is now fully managed — plan, apply, drift detection — with no interruption. Get it wrong and Terraform, seeing a mismatch between your half-written code and reality, proposes to “fix” the difference, which on a azurerm_sql_database or azurerm_storage_account can mean a -/+ destroy and then create replacement that wipes data. The entire discipline of this article is reaching that clean, empty plan safely.
You will learn both routes Terraform gives you. The original terraform import CLI command (one resource at a time, you hand-write the HCL) still has its place. Since Terraform 1.5 there is a far better path for adoption at scale: the declarative import {} block plus -generate-config-out, which generates the resource HCL for you from the live resource, turning a week of hand-transcription into an afternoon of review. By the end you will know which to reach for, how to find the exact Azure resource ID every import needs, how to bend a non-empty plan back to empty, and how to roll back cleanly when an import goes wrong — all without a single second of downtime on the resources you are adopting.
What problem this solves
Click-ops accretes. A startup ships fast in the portal, an enterprise inherits a subscription from an acquisition, a “temporary” proof-of-concept becomes load-bearing — and now there is a sprawl of resources with no source of truth, no review on changes, and no way to stand up an identical environment for DR or staging. Someone changes a firewall rule at 2 a.m., nobody knows, and the next terraform apply from a half-aware teammate silently reverts it. That is configuration drift, and brownfield import is how you end it: once a resource is in Terraform’s state and code, Terraform becomes the authority and drift becomes visible on every plan.
What breaks without this: you cannot safely refactor, peer-review infrastructure changes, or rebuild after a region outage, and every change is a manual portal click no audit trail captures. Worse, teams that try to adopt Terraform naively — writing fresh HCL that “looks like” the existing resource and running apply — learn the hard way that Terraform doesn’t reconcile with reality unless you import: it assumes anything in code that isn’t in state must be created. Point fresh same-named code at an existing resource group and you get a 409 Conflict (“resource already exists”) at best, or a silent duplicate at worst.
Who hits this: nearly every team adopting IaC on a real, running Azure estate. It is the unglamorous, high-stakes bridge between “we have infrastructure” and “we have infrastructure as code.” This article is the bridge. If you are starting clean instead, the import dance is irrelevant — you would author modules from scratch (see Designing Composable Terraform Modules: Interfaces, Versioning, and a Private Registry). Brownfield is the harder, more common reality, and the one where a wrong move costs you data.
Learning objectives
By the end of this article you can:
- Explain what Terraform state is, why import only writes to state (never to the live resource), and why a clean empty plan is the goal of every import.
- Find the exact Azure resource ID for any resource — resource group, storage account, VNet, subnet, App Service, SQL database, role assignment — via
azCLI and the portal, in the precise format the AzureRM provider expects. - Import a single resource with the classic
terraform importcommand and hand-write the matching HCL. - Import many resources declaratively with the
import {}block and auto-generate their HCL using-generate-config-out(Terraform 1.5+). - Read a post-import
terraform plan, distinguish a harmless in-place update from a dangerous replacement (-/+), and drive the plan back to “No changes” without downtime. - Perform basic state surgery —
state list,state show,state rm,state mv— to inspect, repair, or roll back an import. - Adopt a whole brownfield resource group end to end in a repeatable, reviewable, zero-downtime sequence — and tear down a practice copy cleanly.
Prerequisites & where this fits
You should be comfortable with Terraform basics: a .tf file declares resources, terraform init downloads providers, plan previews and apply executes, and the AzureRM provider authenticates to a subscription. You need the Azure CLI (az) logged in (az login) with at least Reader on the resources you import (and Contributor on the RG if you also apply changes), and Terraform 1.5 or newer for the import {} / config-generation path (check terraform version). Cloud Shell already ships both.
This sits at the adoption stage of the IaC journey — after you understand what Terraform is, before you operate it at scale. It assumes auth and backend foundations are in place: import writes to state, so your state must be configured first. If you are still on a local terraform.tfstate, set up a shared remote backend before doing this on anything real (see Terraform on Azure: Remote State in Blob Storage with State Locking & Workspaces). If az login works but Terraform throws 401/403, fix the provider auth first (see Terraform AzureRM Provider Auth Failures: 401/403, Stale Tokens & SPN Permission Fixes). For where Terraform fits among IaC tools, see Infrastructure as Code: Terraform, Pulumi, CDK and Cloud-Native Options.
Here is who owns what during a brownfield adoption, so you escalate to the right person when a plan shows an unexpected diff:
| Concern | What lives here | Who usually owns it | Why it matters to import |
|---|---|---|---|
| Azure resources (live) | The running RG, VNet, storage, app | Platform / app team | The source of truth you import into state |
Terraform code (.tf) |
Resource blocks, variables, providers | IaC / DevOps | Must match the live resource or plan shows a diff |
| State file | The resource-ID ↔ block mapping | IaC / DevOps (in remote backend) | Import writes here; corrupting it is the main risk |
| RBAC / identity | Who Terraform runs as | Identity / security | Import needs Reader; apply needs Contributor |
| Backend storage | Where state lives + locks | Platform | Must exist before import; lock prevents races |
Core concepts
Six mental models make every later step obvious. Internalise these and import stops being scary.
State is the map between your code and Azure. Terraform keeps a JSON state file that records, for every resource it manages, the mapping from a code address (like azurerm_storage_account.data) to a real Azure resource (by its resource ID) plus a snapshot of that resource’s attributes. plan works by reading state, refreshing it against live Azure, comparing both to your code, and proposing the difference. A resource that exists in Azure but not in state is invisible to Terraform — it will try to create it. A resource in state but not in Azure is a ghost — Terraform will try to recreate it. Import’s whole job is to add the correct mapping so neither happens.
Import writes to state, never to the resource. This is the safety guarantee that makes zero-downtime adoption possible. terraform import (and an apply that processes an import {} block) reads the live resource and records it in state. It does not stop, restart, reconfigure, or recreate anything. The live resource serves traffic throughout. The risk is never in the import itself — it is in the next plan, where mismatched code can propose a destructive change. So the rule is: import, then plan, then make the plan empty before you ever apply.
Import does not write your HCL — except when you ask 1.5+ to. With the classic terraform import command you must hand-author a resource block first (even an empty resource "azurerm_storage_account" "data" {} shell), or the import has no address to bind to. After import you fill in every required argument by reading the live resource until the plan goes quiet. The newer import {} block paired with terraform plan -generate-config-out=generated.tf reverses this: Terraform reads the live resource and writes the HCL for you, which you then review and trim. This is the single biggest time-saver for brownfield at scale.
The resource ID is the join key, and its format is provider-specific. Every import needs the Azure Resource Manager (ARM) resource ID — the /subscriptions/.../resourceGroups/.../providers/... path that uniquely names a resource. Most AzureRM resources import by their plain ARM ID, but some need a composite ID (a role assignment is <scope>/providers/Microsoft.Authorization/roleAssignments/<guid>; a subnet is the VNet ID plus /subnets/<name>). The provider docs’ “Import” section for each resource type is the authority on the exact string — never guess it.
A clean plan after import means three numbers are zero. Your success signal is terraform plan reporting No changes. Your infrastructure matches the configuration. — 0 to add, 0 to change, 0 to destroy. Anything else means code and Azure disagree: a handful of in-place changes (~) is usually a benign attribute you forgot to set, but a replacement (-/+, “destroy and then create”) is the danger flag — on a stateful resource it means data loss, and you must resolve it before applying.
Some attributes are not in the API and never come back from import. Write-only values — a SQL admin password, certain connection strings — aren’t returned by the read import performs, so you add them to code by hand afterward. The common offenders get their own troubleshooting rows below.
The vocabulary in one table
The glossary at the end repeats these for lookup; here they are side by side as the mental model:
| Term | One-line definition | Where it lives | Why it matters to import |
|---|---|---|---|
| State | JSON map of code address ↔ live resource + attributes | Backend (blob) / local file | Import adds a mapping here |
| Resource address | type.name[.index], e.g. azurerm_subnet.web |
Your .tf + state |
The target you import into |
| Resource ID | ARM path uniquely naming a resource | Azure | The source you import from |
terraform import (CLI) |
Imperative: bind one ID to one address | Command line | Classic route; you write HCL first |
import {} block |
Declarative import recorded in code | A .tf file |
1.5+ route; supports config-gen |
-generate-config-out |
Writes HCL for imported resources | plan flag (1.5+) |
Auto-authors the resource block |
| Empty plan | 0 to add, 0 to change, 0 to destroy |
plan output |
The goal — code == Azure |
Replacement (-/+) |
Destroy then recreate | plan output |
The danger — possible data loss |
| Drift | Live resource changed outside Terraform | Detected on plan/refresh |
What import lets you see going forward |
| State surgery | state rm / mv / show / list |
Command line | How you inspect or roll back an import |
The two import paths: CLI command vs import {} block
Terraform gives you two ways to adopt an existing resource. They reach the same end state — a resource recorded in state — but the workflow, the scale they suit, and the version they need differ. Choose deliberately.
| Dimension | terraform import (CLI command) |
import {} block (1.5+) |
|---|---|---|
| Terraform version | Any version | 1.5.0+ |
| Writes the HCL for you | No — you hand-author first | Yes, with -generate-config-out |
| Recorded in code / reviewable | No (imperative, ephemeral) | Yes — the block lives in a .tf |
| Dry-run before committing | No — it mutates state immediately | Yes — plan shows the import, nothing changes until apply |
| Good for | A one-off, a quick fix, scripting a loop | Bulk adoption, PR-reviewed onboarding |
| Removes after success | n/a | Delete the import {} block once applied |
| Count/for_each targets | Awkward (address per index) | Cleaner with for_each on the block |
The mental shift: the CLI command is imperative and immediate (state changes the instant you run it, nothing recorded in code), while the import {} block is declarative and previewable (you add a block, plan it like any code change, then apply). For one forgotten resource, the command is faster; for adopting dozens through pull requests, the block is the professional choice. The lab below does both so you own each.
The exact syntax of each, side by side — the command takes an address then an ID as positional arguments; the block names the target and source and is consumed by plan/apply:
| Classic command | import {} block |
|
|---|---|---|
| Form | terraform import <address> "<id>" |
import { to = <address>\n id = "<id>" } |
| Where it goes | Typed at the shell | In a .tf file in the config |
| Address argument | First positional (azurerm_x.name) |
to = azurerm_x.name |
| ID argument | Second positional (quoted) | id = "<resource-id>" |
| Executes when | Immediately on running the command | On terraform apply |
| Generates HCL | Never | With terraform plan -generate-config-out=<file> |
| Provider/var flags | -var, -var-file honoured |
Normal config — variables resolve as usual |
The CLI flags you actually use during a brownfield adoption — for the import command, the config-generation plan, and the state-surgery commands that back it up:
| Command / flag | What it does | Notes / gotcha |
|---|---|---|
terraform import <addr> <id> |
Bind one live resource to one address in state | Address must already exist as a (possibly empty) resource block |
terraform import -var-file=prod.tfvars … |
Supply variables the resource block references | Needed if the block uses vars during import |
terraform plan -generate-config-out=<file> |
Write draft HCL for import {}-targeted resources |
File must not already exist; 1.5+ only |
terraform plan |
Preview; shows planned imports and the diff | Your empty-plan success check |
terraform apply |
Execute imports declared in import {} blocks |
Review for import actions, zero destroys, then confirm |
terraform state list |
List all addresses in state | Confirm an import landed |
terraform state show <addr> |
Print stored attributes of one resource | Read live values to fill/trim HCL |
terraform state rm <addr> |
Forget a resource (Azure untouched) | Non-destructive rollback for a bad import |
terraform state mv <src> <dst> |
Re-key an address in state | Fix a typo’d import target; back up first |
terraform state pull > backup.tfstate |
Dump current state to a file | Run before any state rm/mv |
terraform force-unlock <id> |
Release a stuck state lock | Only if certain the lock is stale |
When to reach for which — a decision table
| If you… | Use | Why |
|---|---|---|
| Need to fix one resource Terraform forgot, fast | terraform import |
One line, no ceremony |
| Are onboarding a whole RG for the first time | import {} + -generate-config-out |
Generates HCL, reviewable in a PR |
| Are on Terraform < 1.5 | terraform import |
Blocks aren’t available yet |
| Want a teammate to review the import before it lands | import {} |
It’s a code change, not a silent state mutation |
| Are scripting import of N similar resources | Either (loop the command, or for_each the block) |
Block scales more cleanly |
| Want zero risk of forgetting what you imported | import {} |
The block documents it in git |
Finding the resource ID — the join key for every import
No import succeeds without the exact resource ID. Azure resource IDs follow a predictable shape, but the per-type details (which provider namespace, whether a child segment is needed) trip people up. Get fluent at producing them three ways: from az CLI (scriptable, exact), from the portal (visual, for one-offs), and from the provider docs (authoritative on composite formats). The canonical ARM ID shape:
/subscriptions/<sub-id>/resourceGroups/<rg>/providers/<rp-namespace>/<type>/<name>
For example a storage account: /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-shop-prod/providers/Microsoft.Storage/storageAccounts/stshopprod001.
Getting IDs from the Azure CLI
The CLI gives you the ID verbatim — copy-paste safe, no transcription errors:
# A simple top-level resource (storage account) — prints its full ARM ID
az storage account show --name stshopprod001 --resource-group rg-shop-prod --query id -o tsv
# A child resource (subnet) — note the composite path the import needs
az network vnet subnet show --vnet-name vnet-shop --name snet-web \
--resource-group rg-shop-prod --query id -o tsv
To list everything in a resource group as name → id pairs — the starting inventory for a bulk adoption:
# Every resource in the RG, with its type and full ID
az resource list --resource-group rg-shop-prod \
--query "[].{name:name, type:type, id:id}" -o table
That single command is your adoption work-list. Save it; each row becomes one import {} block.
Getting IDs from the portal
In the portal, open the resource, go to Settings → Properties (or JSON View, the { } icon at the top-right of the Overview blade on most resources). The Resource ID field is the full ARM path — copy it directly. JSON View is the most reliable because it shows the literal id exactly as ARM stores it, including correct casing of the provider namespace.
The per-type cheat-sheet — the AzureRM resource type, the import ID format, and the CLI that prints it:
| Resource | Terraform type | Import ID format | CLI to get it |
|---|---|---|---|
| Resource group | azurerm_resource_group |
/subscriptions/<sub>/resourceGroups/<rg> |
az group show -n <rg> --query id -o tsv |
| Storage account | azurerm_storage_account |
…/providers/Microsoft.Storage/storageAccounts/<name> |
az storage account show -n <name> -g <rg> --query id -o tsv |
| Virtual network | azurerm_virtual_network |
…/providers/Microsoft.Network/virtualNetworks/<name> |
az network vnet show -n <name> -g <rg> --query id -o tsv |
| Subnet | azurerm_subnet |
…/virtualNetworks/<vnet>/subnets/<name> (composite) |
az network vnet subnet show --vnet-name <vnet> -n <name> -g <rg> --query id -o tsv |
| Network security group | azurerm_network_security_group |
…/providers/Microsoft.Network/networkSecurityGroups/<name> |
az network nsg show -n <name> -g <rg> --query id -o tsv |
| App Service plan | azurerm_service_plan |
…/providers/Microsoft.Web/serverfarms/<name> |
az appservice plan show -n <name> -g <rg> --query id -o tsv |
| Web app | azurerm_linux_web_app / azurerm_windows_web_app |
…/providers/Microsoft.Web/sites/<name> |
az webapp show -n <name> -g <rg> --query id -o tsv |
| SQL server | azurerm_mssql_server |
…/providers/Microsoft.Sql/servers/<name> |
az sql server show -n <name> -g <rg> --query id -o tsv |
| SQL database | azurerm_mssql_database |
…/servers/<server>/databases/<name> (composite) |
az sql db show -n <name> -s <server> -g <rg> --query id -o tsv |
| Key Vault | azurerm_key_vault |
…/providers/Microsoft.KeyVault/vaults/<name> |
az keyvault show -n <name> --query id -o tsv |
| Role assignment | azurerm_role_assignment |
<scope>/providers/Microsoft.Authorization/roleAssignments/<guid> |
az role assignment list --scope <scope> --query "[].id" -o tsv |
Two ID gotchas that cause most “import failed / not found” errors:
| Gotcha | What goes wrong | Fix |
|---|---|---|
| Casing of the provider namespace | microsoft.storage vs Microsoft.Storage — some IDs are case-sensitive on import |
Copy from JSON View / az, never retype |
| Missing the child segment | Importing a subnet with only the VNet ID fails | Use the composite …/virtualNetworks/<vnet>/subnets/<name> |
Reading the post-import plan — empty, in-place, or replacement
Import is half the job; the plan after import is the other half — the part that actually protects production. After every import, run terraform plan and read the three numbers in its summary line:
| Plan summary | Meaning | Severity | Action |
|---|---|---|---|
No changes. … 0 to add, 0 to change, 0 to destroy |
Code matches Azure exactly | Done | Commit; the resource is fully managed |
~ N to change (in-place ~) |
An attribute differs but updates without recreating | Usually benign | Set the attribute in code to match → re-plan |
+ N to add |
Terraform thinks a child/sub-resource is missing | Investigate | You likely under-modelled a nested block; import or model it |
-/+ N to destroy and create |
Replacement — destroy then recreate | Dangerous | STOP. Find the forcing attribute; fix before apply |
- N to destroy |
Code is missing a block that’s in state | Investigate | You removed a block that exists; restore it |
The icons in the plan body tell you which attribute is the culprit:
| Plan symbol | Means | Typical cause after import |
|---|---|---|
~ |
Update in place | A tag, SKU tier, or setting you didn’t copy into code |
+ |
Add | A nested/child block Terraform expects but you omitted |
- |
Destroy | A block in state but absent from code |
-/+ |
Replace (destroy then create) | A ForceNew attribute mismatch (e.g. location, name) |
# forces replacement |
The exact line driving a -/+ |
Read this line — it names the attribute to fix |
The golden rule: never apply a plan that shows -/+ or - on a resource you just imported until you understand exactly why — a replacement on a azurerm_mssql_database, azurerm_storage_account, or azurerm_key_vault can destroy data. The fix is almost always to set the mismatched ForceNew attribute (named on the plan’s # forces replacement line) to the live value, then re-plan until the summary reads No changes.
What commonly forces a replacement after import
These are the attributes whose mismatch turns a benign import into a destroy-and-recreate. Copy the live value into your code:
| Attribute | Resource types | Why it forces replacement | How to get the live value |
|---|---|---|---|
name |
Almost all | The resource’s identity | From the resource ID / az ... show |
location |
Almost all | Region is immutable | az resource show --ids <id> --query location -o tsv |
resource_group_name |
Almost all | RG is part of identity | From the resource ID |
account_tier / account_replication_type |
azurerm_storage_account |
Some changes are recreate-only | az storage account show … --query sku |
address_space (wrong) |
azurerm_virtual_network |
Re-CIDR can be recreate | az network vnet show … --query addressSpace |
administrator_login |
azurerm_mssql_server |
Admin name is immutable | az sql server show … --query administratorLogin |
State surgery basics — inspect, repair, roll back
Import touches state, so you must be able to read and edit state safely. Four commands cover almost everything. Treat every one of these as a production-grade operation — back up state first (covered in the lab), and never run them while another apply holds the lock.
| Command | What it does | When you use it for import | Risk |
|---|---|---|---|
terraform state list |
Lists every address in state | Confirm an import landed; inventory | Read-only, safe |
terraform state show <addr> |
Prints the stored attributes of one resource | Read live values to fill in HCL | Read-only, safe |
terraform state rm <addr> |
Removes a resource from state (leaves Azure untouched) | Roll back a bad import | Resource becomes unmanaged again — Azure not deleted |
terraform state mv <src> <dst> |
Renames/moves an address within state | Fix a wrong address after import; refactor into modules | Wrong target corrupts state — back up first |
The two facts that make these safe: terraform state rm does NOT delete the Azure resource — it only forgets it, so it is your clean rollback for a botched import (the resource keeps running; re-import correctly). And terraform state mv only re-keys state. Neither ever touches the live resource — both edit only the state JSON, which is why backing up state before surgery is mandatory.
Architecture at a glance
Picture three planes and the single arrow import draws between two of them. The Azure control plane holds the live, running resources — the RG, VNet, storage account, App Service — each addressable by a unique ARM resource ID, all serving traffic right now and indifferent to whether Terraform knows they exist. The Terraform state is a JSON document (in your blob backend, or a local file for a lab) mapping code addresses to resource IDs and caching each resource’s attributes — Terraform’s entire model of reality. The Terraform code is your .tf files declaring the desired shape of each resource. plan triangulates the three: it refreshes state against live Azure, diffs that against your code, and reports the delta.
Before import, a click-ops resource exists in only one plane — the Azure control plane. It is in neither state nor code, so Terraform is blind to it; ask Terraform to manage a like-named resource and it tries to create, colliding with the live one. Import draws the missing arrow: it reads the live resource by its ID and writes the mapping into state, with no call that mutates the resource — the App Service never restarts, the database never drops a connection. You then hand-write the resource’s code block (CLI path) or let -generate-config-out write it for you (block path), and iterate plan until the diff is empty. At that moment the resource is in all three planes and in agreement, fully managed, with every future change flowing through plan/apply and drift visible on each run. The whole adoption is moving each resource from “in Azure only” to “in all three, in agreement,” one arrow at a time — and because that arrow only ever writes state, never the resource, the lights stay on throughout.
Real-world scenario
Northwind Retail (a fictional but representative mid-market e-commerce outfit) ran its production stack entirely from the portal: one resource group rg-northwind-prod holding a Linux App Service serving the storefront, an Azure SQL database behind it, a storage account for product images, a VNet with two subnets, and a Key Vault holding the SQL connection string. Three engineers had each clicked changes over eighteen months; nobody could say what the “correct” configuration was, and a botched manual firewall edit had taken checkout down for forty minutes the previous quarter. The mandate from their new platform lead, Asha, was blunt: get production into Terraform within a sprint, with zero downtime, before peak season froze all changes.
Asha’s team first stood up a remote backend (a dedicated storage account and container for state, with locking) so the adoption itself was safe and shared. Then she ran az resource list -g rg-northwind-prod -o table and got a fourteen-row work-list. Rather than hand-write fourteen resource blocks, they used the Terraform 1.5 path: one import {} block per resource ID, then terraform plan -generate-config-out=generated.tf, which produced a draft with every argument filled from the live resources. That first draft did not plan clean — and reviewing it carefully is exactly what saved them. The storage account showed a -/+ replacement: a teammate had quietly upgraded it to GRS in the portal months earlier, the generator correctly read the live GRS, but an over-eager reviewer “corrected” it back to LRS in the draft. Catching that # forces replacement line in review — before any apply — averted a destroy-and-recreate on the image store.
The Key Vault import surfaced the classic write-only gap: the SQL admin password wasn’t in any read, so the azurerm_mssql_server block had no administrator_login_password. They wired it to a variable sourced from the existing Key Vault secret rather than committing it, and moved on. The subnets needed the composite ID format (…/virtualNetworks/vnet-northwind/subnets/snet-web) — their first attempt with the bare VNet ID failed with “cannot import non-existent remote object,” a two-minute fix once they read the error. After three review cycles the plan went fully empty: 0 to add, 0 to change, 0 to destroy. Total wall-clock: two afternoons, zero downtime, no customer impact, and every firewall change now goes through a pull request — the stray-click outage cannot recur, because portal changes surface as drift on the next plan and get reverted on purpose, not by accident.
Advantages and disadvantages
Brownfield import is the right move for almost any running Azure estate, but it is not free of cost or risk. The honest trade-off:
| Advantages | Disadvantages |
|---|---|
| Existing production comes under IaC with zero downtime | Up-front effort: every resource must be imported and reconciled to an empty plan |
| Ends configuration drift — portal changes become visible on every plan | Risk window: a wrong plan can propose data-loss -/+ if you apply carelessly |
| Enables review, audit, reproducibility, DR rebuilds | Write-only attributes (passwords, secrets) need manual handling |
import {} + config-gen turns weeks of transcription into hours |
Generated HCL is a draft — it still needs human review and trimming |
State surgery (state rm) gives a clean, non-destructive rollback |
State is now a critical asset — corrupt it and you have a new problem |
| Incremental: adopt one RG at a time, no big bang | Composite IDs and per-type quirks have a learning curve |
When the advantages dominate: any resource that serves real traffic, changes over time, or needs to be reproducible — nearly all production. When the disadvantages bite hardest: very large estates imported in one undisciplined sweep (do it RG by RG instead), and teams that skip the “empty plan before apply” rule (where the data-loss stories come from). The cost is real but one-time; drift-elimination and reproducibility pay back on every subsequent change.
Hands-on lab
This is the centerpiece. You will create a small practice brownfield estate by hand (simulating click-ops), then adopt it into Terraform two ways: the classic terraform import command for one resource, and the modern import {} + config-generation flow for the rest — verifying an empty plan at every step and tearing the practice copy down at the end. Everything here is free-tier-friendly (a resource group, a storage account, a VNet/subnet — all in the always-free or trivially-cheap tier) and runs in Cloud Shell or any shell with az and terraform ≥ 1.5.
Safety note: do this in a throwaway resource group first, exactly as written, before you ever import real production. The teardown step deletes only the practice RG.
Step 0 — Prerequisites and a working folder
Confirm your tools and pick a unique suffix so names don’t collide globally.
az version --query '"azure-cli"' -o tsv # any recent version
terraform version # must be >= 1.5.0
az account show --query "{sub:id, name:name}" -o table # confirm the target subscription
# A unique-ish suffix for globally-unique names (storage)
SUFFIX=$RANDOM
echo "Using suffix: $SUFFIX"
mkdir -p ~/tf-brownfield-lab && cd ~/tf-brownfield-lab
Expected output: a Terraform version line of Terraform v1.5.x or higher, your subscription printed, and a folder created. If terraform version is below 1.5, the import {} steps won’t work — upgrade, or do only the CLI-command part.
Step 1 — Create the “click-ops” resources (simulating brownfield)
Pretend a colleague built these in the portal months ago. We create them with az for reproducibility, but the point is that Terraform did not create them and has no state for them — exactly the brownfield condition.
LOC=eastus
RG=rg-brownfield-lab
SA=stbrownfield$SUFFIX # storage account names: 3-24 chars, lowercase+digits, globally unique
VNET=vnet-brownfield
SUBNET=snet-app
# Resource group
az group create --name $RG --location $LOC -o table
# Storage account (Standard LRS — cheapest)
az storage account create --name $SA --resource-group $RG --location $LOC \
--sku Standard_LRS --kind StorageV2 --tags env=lab owner=you -o table
# Virtual network + a subnet
az network vnet create --name $VNET --resource-group $RG --location $LOC \
--address-prefix 10.42.0.0/16 --subnet-name $SUBNET --subnet-prefix 10.42.1.0/24 -o table
Expected output: three successful az ... create calls. Verify the inventory — this is your adoption work-list:
az resource list --resource-group $RG --query "[].{name:name, type:type, id:id}" -o table
You should see the storage account and the VNet (the subnet is a child of the VNet, so it may not list as a top-level resource — that’s normal). Capture the IDs you’ll need:
SA_ID=$(az storage account show -n $SA -g $RG --query id -o tsv)
VNET_ID=$(az network vnet show -n $VNET -g $RG --query id -o tsv)
SUBNET_ID=$(az network vnet subnet show --vnet-name $VNET -n $SUBNET -g $RG --query id -o tsv)
RG_ID=$(az group show -n $RG --query id -o tsv)
echo "$RG_ID"; echo "$SA_ID"; echo "$VNET_ID"; echo "$SUBNET_ID"
Expected output: four ARM IDs printed, each starting /subscriptions/…. Note that SUBNET_ID ends in …/virtualNetworks/vnet-brownfield/subnets/snet-app — the composite child format.
Step 2 — Initialise Terraform (provider + backend)
Create the provider configuration. For the lab we use local state (simplest); on anything real you would point this at a blob backend instead (linked in Prerequisites).
cat > providers.tf <<'EOF'
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.110"
}
}
}
provider "azurerm" {
features {}
}
EOF
terraform init
Expected output: Terraform has been successfully initialized! and the AzureRM provider downloaded. A .terraform/ folder and .terraform.lock.hcl appear.
Step 3 (Path A) — Import ONE resource with the classic CLI command
We adopt the resource group itself the old-fashioned way to learn the imperative flow: write an empty resource shell, import, then fill in HCL until the plan is empty.
First, a minimal resource block — the import needs an address to bind to:
cat > resource_group.tf <<'EOF'
resource "azurerm_resource_group" "lab" {
# Intentionally minimal — we will fill these from the live resource after import
name = "rg-brownfield-lab"
location = "eastus"
}
EOF
Now import, binding the live RG ID to the azurerm_resource_group.lab address:
terraform import azurerm_resource_group.lab "$RG_ID"
Expected output: azurerm_resource_group.lab: Importing from ID "..." followed by Import successful!. The live RG was not modified — only state was written. Confirm:
terraform state list
terraform state show azurerm_resource_group.lab
Expected output: state list prints azurerm_resource_group.lab; state show prints its attributes (name, location, tags, id). Now the critical check — does code match Azure?
terraform plan
Expected output: No changes. Your infrastructure matches the configuration. (0 to add, 0 to change, 0 to destroy). If instead you see a ~ change on tags, copy the live tags into the block and re-plan. The resource group is now fully managed, with zero downtime.
Step 4 (Path B) — Import the REST with import {} blocks + config generation
This is the path you’ll actually use for brownfield at scale. Instead of hand-writing the storage account, VNet and subnet, we declare import {} blocks and let Terraform generate the HCL.
Create an imports file with one block per resource (use the real IDs you captured — substitute them, or use the here-doc with shell expansion shown):
cat > imports.tf <<EOF
import {
to = azurerm_storage_account.lab
id = "$SA_ID"
}
import {
to = azurerm_virtual_network.lab
id = "$VNET_ID"
}
import {
to = azurerm_subnet.lab
id = "$SUBNET_ID"
}
EOF
Now run plan with -generate-config-out — Terraform reads each live resource and writes a draft HCL block for it:
terraform plan -generate-config-out=generated.tf
Expected output: Terraform reports it will import three resources and has written their configuration to generated.tf. The plan summary should read something like Plan: 3 to import, 0 to add, 0 to change, 0 to destroy. Crucially this is a dry run — nothing has changed in Azure or state yet. Inspect the generated file:
cat generated.tf
Expected output: three resource blocks (azurerm_storage_account.lab, azurerm_virtual_network.lab, azurerm_subnet.lab) with arguments populated from the live resources — name, location, SKU, address space. The generator is thorough but verbose, often emitting computed attributes and defaults you should trim. For the lab leave it as-is; in production, remove computed-only fields and replace any literal secret with a variable reference.
Now apply to perform the imports (with import {} blocks, the import executes on apply):
terraform apply
Review the planned actions — they should all be imports, with no destroys. Type yes. Expected output: Apply complete! reporting 3 imported, 0 added, 0 changed, 0 destroyed. Confirm everything landed:
terraform state list
Expected output: all four addresses now present:
azurerm_resource_group.lab
azurerm_storage_account.lab
azurerm_subnet.lab
azurerm_virtual_network.lab
Step 5 — Verify the empty plan (the success signal)
The whole point: prove code and Azure agree, so future applys are safe and drift becomes visible.
terraform plan
Expected output: No changes. Your infrastructure matches the configuration. If you see ~ in-place changes, it’s almost always a trimmed-away attribute — restore it in generated.tf to match the live value (read it with terraform state show <addr>) and re-plan. Do not proceed past a -/+ replacement — find the # forces replacement line, set that attribute to the live value, and re-plan until clean.
Step 6 — Clean up the import blocks (housekeeping)
Once imports have applied, the import {} blocks have done their job. Best practice is to delete them so they don’t re-run or clutter the code; the resources stay managed via state.
rm imports.tf
terraform plan # still "No changes" — the resources stay managed
Expected output: No changes. Removing the import blocks does not un-manage anything; the mapping lives in state now. You’re left with clean, reviewable resource HCL.
Step 7 — Prove drift detection works (the payoff)
Make a “rogue portal change” out-of-band, then watch Terraform catch it — this is exactly what you bought.
# Simulate someone changing a tag in the portal
az storage account update -n $SA -g $RG --set tags.changedby=rogue -o none
terraform plan
Expected output: the plan now shows a ~ in-place change on azurerm_storage_account.lab proposing to remove the changedby tag (it’s not in your code). Drift is now visible — you’d either accept it into code or apply to revert it, both deliberate reviewed actions instead of silent surprises.
Step 8 — (Optional) Practise a rollback with state surgery
See that state rm un-manages without deleting. (Re-import afterward to restore.)
terraform state rm azurerm_subnet.lab # forget the subnet (Azure untouched)
terraform state list # subnet gone from state
az network vnet subnet show --vnet-name $VNET -n $SUBNET -g $RG --query name -o tsv # still exists in Azure!
# Re-import to restore management
terraform import azurerm_subnet.lab "$SUBNET_ID"
terraform plan # back to "No changes"
Expected output: after state rm, the subnet is absent from state list but the az query still prints snet-app — proving the resource lives on. After re-import, the plan is clean again. This is your safe rollback for any botched import.
Step 9 — Teardown
Delete only the practice RG and the local Terraform files. (We destroy via az so you also see that Terraform’s destroy would do the same on managed resources.)
# Option A: let Terraform destroy what it manages (everything we imported)
terraform destroy # type 'yes' — removes the SA, VNet, subnet, and RG
# Option B (belt-and-braces): ensure the RG is gone regardless of state
az group delete --name $RG --yes --no-wait
# Remove local lab files
cd ~ && rm -rf ~/tf-brownfield-lab
Expected output: Destroy complete! (Option A) and/or the RG deletion accepted (Option B). Verify nothing remains:
az group exists --name rg-brownfield-lab # should print: false
You have now adopted a brownfield estate two ways, verified an empty plan, watched drift detection fire, practised a non-destructive rollback, and cleaned up — the complete zero-downtime import lifecycle. The whole lab on one page, as a checklist you can re-run:
| Step | Goal | Key command | Success signal |
|---|---|---|---|
| 0 | Verify tooling + folder | terraform version |
v1.5.x+ printed |
| 1 | Create “click-ops” resources | az group/storage/network … create |
RG + SA + VNet exist; IDs captured |
| 2 | Init Terraform | terraform init |
“successfully initialized” |
| 3 (A) | Import one resource (CLI) | terraform import azurerm_resource_group.lab "$RG_ID" |
“Import successful!” then plan = No changes |
| 4 (B) | Generate + import the rest | terraform plan -generate-config-out=generated.tf then apply |
3 imported, 0 destroyed |
| 5 | Verify empty plan | terraform plan |
0 to add, 0 to change, 0 to destroy |
| 6 | Remove import blocks | rm imports.tf + terraform plan |
Still No changes |
| 7 | Prove drift detection | az storage account update --set tags.* + plan |
A ~ change appears |
| 8 | Practise rollback | terraform state rm … then re-import |
Resource survives in Azure; plan clean after re-import |
| 9 | Teardown | terraform destroy / az group delete |
az group exists → false |
Common mistakes & troubleshooting
The failure modes below are where brownfield imports actually go wrong in the field — symptom, root cause, the exact way to confirm it, and the fix.
| # | Symptom | Root cause | Confirm with | Fix |
|---|---|---|---|---|
| 1 | First plan after import shows -/+ (replace) on a stateful resource |
A ForceNew attribute in code differs from live (often location, name, a SKU) |
Read the # forces replacement line in the plan |
Set that attribute to the live value; re-plan to empty — do not apply yet |
| 2 | Error: Cannot import non-existent remote object |
Wrong / malformed resource ID, or wrong resource type | az resource show --ids "<id>" returns the resource? |
Recopy the ID from az ... show or JSON View; check the composite child path |
| 3 | Resource already managed by Terraform |
Address already in state (double import) | terraform state list | grep <addr> |
Use a new address, or state rm then re-import |
| 4 | Subnet/database import fails with “not found” but the resource exists | Used the parent ID, not the composite child ID | Compare your ID to az network vnet subnet show … --query id |
Use …/virtualNetworks/<vnet>/subnets/<name> form |
| 5 | After importing fresh code, apply proposes to create a resource that already exists → 409 Conflict |
You wrote HCL but never imported — Terraform thinks it must create it | terraform state list shows the address missing |
Import the existing resource into that address first, then plan |
| 6 | Plan shows ~ administrator_login_password or wants a secret you didn’t set |
Write-only attribute not returned by the import read | terraform state show <addr> lacks the value |
Add the value via a variable / Key Vault reference; mark sensitive |
| 7 | -generate-config-out errors: “config file already exists” |
Target file is present from a previous run | ls generated.tf |
Delete/rename the old file, or point to a fresh filename |
| 8 | Import “succeeds” but plan shows dozens of ~ changes |
Generated/hand HCL kept computed or default attributes that don’t match | Diff generated.tf against state show |
Trim computed-only fields; set the few that genuinely differ |
| 9 | Error acquiring the state lock during import |
Another apply/import holds the backend lock | terraform force-unlock (only if you’re sure it’s stale) |
Wait for the other run; never force-unlock a live operation |
| 10 | Imported into the wrong address (typo in resource name) | terraform import azurerm_x.typo … |
terraform state list shows the typo’d address |
terraform state mv azurerm_x.typo azurerm_x.correct |
| 11 | import {} block runs again on a later apply |
You left the block in code after it applied | grep -r 'import {' *.tf |
Delete the import {} block; state already holds the mapping |
| 12 | Authentication 401/403 on terraform plan/import while az works |
Provider auth mismatch (stale token, wrong sub, missing role) | terraform plan error vs az account show |
Fix provider auth — see the auth-failures article linked in Prerequisites |
Two of these deserve a longer word.
The -/+ replacement (row 1) is the one that loses data. When you import a azurerm_mssql_database or azurerm_storage_account and the first plan wants to replace it, a recreate-only attribute in your code doesn’t match Azure. The plan body names it on a # forces replacement line; set your code to the live value (the lab’s Step 5 shows the pattern) and re-plan until the summary reads No changes. Only then is it safe to apply. This single discipline — empty plan before apply — is the whole safety story.
Write-only secrets (row 6) are expected, not a bug. The ARM API never returns a password, so administrator_login_password, certain connection strings, and similar values come back empty from import. Wire them to a variable or an existing Key Vault secret rather than committing a literal, and Terraform stops asking. For how Terraform itself should authenticate to read those secrets, see Managed Identities Demystified: System vs User-Assigned and When to Use Each.
Best practices
- Make the plan empty before you apply — always. A post-import plan showing anything other than
No changesis unfinished work. Resolve every~, and neverapplya-/+on a stateful resource until you’ve eliminated it. - Use a remote backend with locking before importing anything real. Import writes state; state must be shared, versioned, and locked so two engineers can’t corrupt it mid-adoption. Set this up first.
- Back up state before any
state rm/state mv.terraform state pull > backup.tfstate(or rely on blob versioning). State surgery is powerful and unforgiving. - Prefer
import {}+ config-generation for bulk adoption. It’s reviewable in a pull request, previewable withplan, and writes the HCL draft for you. Reserve the CLI command for one-offs. - Adopt one resource group at a time. Incremental import keeps each plan small and each diff readable; big-bang subscription imports are where mistakes hide.
- Treat generated HCL as a draft, not gospel.
-generate-config-outover-emits computed and default attributes. Trim them; keep only what defines intent. Replace literal secrets with variables. - Import the resource ID from
az/JSON View, never by hand. Casing and composite child paths cause most “not found” failures; copy-paste eliminates them. - Tag and name consistently so future imports are predictable. A clean naming/tag scheme makes the adoption work-list (
az resource list) self-documenting — see Azure Tagging Strategy 101: A Naming and Tag Schema for Cost Allocation. - Commit the import as its own reviewable change. One PR: the
import {}blocks plus the generated-and-trimmed HCL, with a green empty plan in CI as the merge gate. - Delete
import {}blocks once applied. They’ve done their job; leaving them is clutter and a re-run risk. - Pin the provider version during adoption. Different AzureRM versions read slightly different attribute sets; a mid-adoption upgrade can turn an empty plan noisy.
- Prevent future click-ops with policy. Once imported, stop the drift at the source — deny or audit out-of-band changes with Azure Policy (see Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists).
Security notes
Brownfield import touches three security-sensitive surfaces: the identity Terraform runs as, the state file it writes, and the secrets that surface (or fail to surface) during adoption.
- Least privilege for the import identity. Importing only reads live resources, so the principal needs Reader on what you import; Contributor only when you also intend to
applychanges. Don’t run adoption as Owner; scope the role to the resource group, not the subscription, where possible. - State contains secrets — protect it accordingly. State caches resource attributes, some sensitive (connection strings, keys the API does return). Store it in a private blob container with encryption at rest, restrict access with RBAC, enable versioning/soft-delete, and never commit
.tfstateto git — a core reason the remote backend is a prerequisite, not an afterthought. - Never commit write-only secrets you add by hand. When you wire up
administrator_login_passwordor a connection string that import couldn’t read, source it from Key Vault (a data source / Key Vault reference) or a sensitive variable injected at apply time — not a literal in.tf. Mark the variablesensitive = trueso it’s redacted from plan output and logs. - Audit the adoption. Because import is a state mutation, run it through the same review and audit path as any change: a pull request, a CI plan, an approval. That trail is half the reason you’re adopting IaC in the first place.
- Mind the blast radius of
applyduring adoption. A carelessapplyon a not-yet-clean plan can change or replace a live resource. Keep applies gated behind a verified empty plan, and prefer plan-only CI until the resource group is fully reconciled.
Cost & sizing
Import itself is free — metadata work against the ARM API that writes a state file; no compute, no per-import charge. The costs around brownfield adoption are small and indirect:
| Cost driver | What it is | Rough figure | How to minimise |
|---|---|---|---|
| Remote state storage | Blob storage account + container for .tfstate |
A few INR/month (~₹5–20) — kilobytes of data | One small Standard LRS account for all state |
| State operations | List/lock blob calls during plan/apply | Negligible (fractions of a paisa) | Nothing to do |
| Lab resources (this article) | Practice RG: storage + VNet | ~₹0–10 for the hour, ₹0 if torn down | Run teardown (Step 9) promptly |
| Engineer time | The actual cost of adoption | Hours to days per estate | Use import {} + config-gen; go RG by RG |
| The resources you adopt | Unchanged — import doesn’t alter them | Same as before | Import is cost-neutral on live resources |
The thing to internalise: import does not change your Azure bill — the resources you adopt cost exactly what they cost before; you’ve only changed how they’re managed. The only new line item is a trivially small state-storage account; the only real “cost” is the one-time engineering effort, which the import {}/generation path slashes.
Interview & exam questions
These map to the HashiCorp Terraform Associate and the IaC portions of AZ-104 / AZ-400.
1. What does terraform import actually modify — the resource, the state, or the code? Only the state. It reads the live resource and records the mapping (address ↔ ID + attributes) in state. It never modifies the live Azure resource and never writes your HCL (the classic command, that is). This is why import is zero-downtime.
2. After importing a resource, the first plan shows -/+ destroy and then create. What’s happening and what do you do? Your code has a ForceNew attribute that doesn’t match the live resource, so Terraform wants to recreate it. The plan’s # forces replacement line names the attribute. Set it to the live value and re-plan until you get No changes. Never apply the replacement on a stateful resource — it can destroy data.
3. Difference between the terraform import command and the import {} block? The command (any version) is imperative: it mutates state immediately, you hand-write the HCL, and nothing is recorded in code. The import {} block (1.5+) is declarative: it lives in a .tf, is previewable with plan, can generate the HCL via -generate-config-out, and executes on apply. The block is the choice for reviewable, bulk adoption.
4. What is -generate-config-out and when do you use it? A terraform plan flag (1.5+) that writes draft HCL for resources targeted by import {} blocks, reading their configuration from the live resources. You use it to adopt many brownfield resources without hand-transcribing every argument — then review and trim the generated file.
5. How do you find the resource ID needed for an import? From az ... show --query id -o tsv, or the portal’s JSON View / Properties (Resource ID field), or the provider docs’ “Import” section for composite formats. Always copy it rather than retype — casing and composite child paths (subnets, databases) cause most failures.
6. Why might a clean plan show ~ administrator_login_password after importing a SQL server? It’s a write-only attribute the ARM API doesn’t return, so import couldn’t populate it. You add it via a variable or Key Vault reference (marked sensitive), and the diff resolves.
7. What does terraform state rm do, and why is it safe? It removes a resource from state only — the live Azure resource is untouched and keeps running. It’s the standard non-destructive rollback for a botched import: state rm the bad address and re-import correctly.
8. You wrote fresh HCL for a resource that already exists and ran apply. What happens? Terraform, seeing the resource in code but not in state, tries to create it — colliding with the live resource (typically a 409 Conflict “already exists”). The fix is to import the existing resource into that address first, then plan.
9. What’s the difference between greenfield and brownfield in Terraform terms? Greenfield: Terraform creates resources from nothing. Brownfield: resources already exist (often click-ops) and you import them into state to bring them under management without recreating them.
10. Why must the import identity have at least Reader, and when does it need Contributor? Import reads the live resource, so Reader suffices to import. You need Contributor only when you’ll also apply changes (which mutate resources). Least privilege says scope it tightly and don’t import as Owner.
11. After importing with import {} blocks and applying, what should you do with the blocks? Delete them. They’ve recorded the mapping in state; leaving them is clutter and risks re-running. The resources stay managed without the blocks.
12. How do you fix a resource imported into the wrong address (a typo)? terraform state mv <wrong-address> <correct-address> — it re-keys state only, touching nothing live. Back up state first.
Quick check
- Does
terraform importever modify the live Azure resource? - You import a storage account and the first plan says
-/+ 1 to destroy and create. Safe to apply? - Which Terraform version introduced the
import {}block and-generate-config-out? - What’s the safe, non-destructive way to roll back a wrong import?
- Why might
administrator_login_passwordshow as a change right after importing a SQL server?
Answers
- No. Import writes only to state — it reads the live resource and records the mapping. The resource keeps serving traffic; this is the zero-downtime guarantee.
- No — stop. A
-/+is a destroy-and-recreate that can lose data. A ForceNew attribute in your code doesn’t match live; read the# forces replacementline, set that attribute to the live value, and re-plan untilNo changesbefore applying. - Terraform 1.5. Earlier versions only have the imperative
terraform importcommand. terraform state rm <address>— it removes the resource from state without deleting it in Azure, so you can re-import correctly. (Back up state first.)- It’s a write-only attribute the ARM API doesn’t return, so import couldn’t read it. Supply it via a sensitive variable or Key Vault reference and the diff resolves.
Glossary
- Brownfield — Adopting resources that already exist (often built by hand) into IaC, versus greenfield where IaC creates everything from scratch.
- Import — Recording an existing resource in Terraform state (and, with 1.5+, optionally generating its HCL) so Terraform manages it without recreating it.
- State — Terraform’s JSON record mapping each code address to a live resource ID plus a cache of its attributes; the model
plandiffs against. - Resource ID (ARM ID) — The unique
/subscriptions/.../providers/...path that names an Azure resource; the join key every import needs. - Resource address — A resource’s Terraform identity,
type.name[.index](e.g.azurerm_subnet.web); the import target. import {}block — A declarative import (Terraform 1.5+) written in a.tffile, previewable withplanand applied withapply.-generate-config-out— Aplanflag (1.5+) that writes draft HCL forimport {}-targeted resources by reading them live.- Empty plan —
planreporting0 to add, 0 to change, 0 to destroy— the signal that code and Azure agree; the goal of import. - Replacement (
-/+) — A plan action that destroys then recreates a resource, driven by a ForceNew attribute mismatch; on stateful resources it risks data loss. - ForceNew attribute — An attribute whose change requires recreating the resource (e.g.
location,name); a mismatch after import forces a replacement. - Drift — A live resource changed outside Terraform; becomes visible on
plan/refreshonce the resource is managed. - State surgery — Direct manipulation of state with
state list/show/rm/mv, used to inspect or roll back imports. terraform state rm— Removes a resource from state only (Azure untouched); the standard non-destructive rollback for a bad import.terraform state mv— Re-keys a resource’s address within state without touching the live resource; fixes a wrong/typo’d import address.- Write-only attribute — A value the API never returns (e.g. a password); not populated by import, supplied by hand afterward.
Next steps
- Set up the shared state your imports must write to: Terraform on Azure: Remote State in Blob Storage with State Locking & Workspaces.
- If Terraform throws 401/403 while
azworks, fix the provider auth: Terraform AzureRM Provider Auth Failures: 401/403, Stale Tokens & SPN Permission Fixes. - Refactor your newly-imported flat HCL into reusable, versioned modules: Designing Composable Terraform Modules: Interfaces, Versioning, and a Private Registry.
- Stop future click-ops drift at the source with policy enforcement: Azure Policy Effects Decoded: Deny vs Audit vs Modify vs DeployIfNotExists.
- Place this in the bigger IaC picture and compare tooling choices: Infrastructure as Code: Terraform, Pulumi, CDK and Cloud-Native Options.