Azure Infrastructure as Code

Importing Brownfield Azure Resources into Terraform Without Downtime

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:

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 Yesplan 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 existsfalse

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

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.

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

  1. Does terraform import ever modify the live Azure resource?
  2. You import a storage account and the first plan says -/+ 1 to destroy and create. Safe to apply?
  3. Which Terraform version introduced the import {} block and -generate-config-out?
  4. What’s the safe, non-destructive way to roll back a wrong import?
  5. Why might administrator_login_password show as a change right after importing a SQL server?

Answers

  1. 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.
  2. 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 replacement line, set that attribute to the live value, and re-plan until No changes before applying.
  3. Terraform 1.5. Earlier versions only have the imperative terraform import command.
  4. 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.)
  5. 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

Next steps

AzureTerraformInfrastructure as CodeImportBrownfieldState ManagementAzureRMDevOps
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading