The Core Workflow & State
Why this matters
init → plan → apply → destroy is the loop you'll run thousands of
times. More importantly, this module introduces the state file — the
single most misunderstood and most dangerous part of Terraform. State is how
Terraform remembers what it created; corrupt it, lose it, or let reality
drift away from it, and Terraform gets confused in ways that can delete real
infrastructure. Understanding it now prevents expensive mistakes later.
Concepts
The core workflow
Four commands do the everyday work, and you'll internalize this rhythm:
terraform init— prepares a working directory: downloads the providers your config declares, sets up the backend (where state lives), and writes the dependency lock file. Run once per directory, and again whenever you add a provider or module. Safe to re-run; it's idempotent.terraform plan— computes the difference between your config (desired) and state+reality (current), and prints exactly what it would do, without doing it. This is your safety check. Read it every time.terraform apply— runs a plan and, after you confirm, actually makes the API calls to create/update/delete resources, then records the result in state.terraform destroy— the inverse of apply: deletes everything in the state file. This is how you avoid leaving billable resources running.
Reading a plan
Every plan prints a symbol per resource and a summary line. Learn to read both at a glance:
| Symbol | Meaning |
|---|---|
+ |
create |
- |
destroy |
~ |
update in place |
-/+ |
destroy and recreate (replacement — pay attention) |
<= |
read (data source) |
The last line is the one that matters most:
Plan: 1 to add, 0 to change, 0 to destroy. If those numbers don't match
your mental model of what you asked for, stop and figure out why before
typing yes — this is where a count mistake or an accidental
replacement gets caught.
What the state file is
When Terraform creates a resource, it records a mapping in a JSON file
(terraform.tfstate by default, in your working directory) between the
resource address in your config (e.g.
azurerm_resource_group.main) and the real object in Azure (its
resource ID, plus every attribute's current value). State is Terraform's
memory. Without it, Terraform would have no idea that the
azurerm_resource_group.main in your config corresponds to the actual
rg-tf-learn already in Azure.
Why state matters (and why it's dangerous)
State is what lets plan be fast and precise: instead of querying all of
Azure, Terraform reads state, refreshes the specific resources it tracks,
and diffs against your config. Consequences of that design:
- State is the source of truth about what Terraform manages. If a resource isn't in state, Terraform doesn't know it exists (even if it's in Azure). If it's in state but you delete the config block, Terraform will destroy it.
- Deleting or corrupting state is catastrophic. Lose the state file and
Terraform forgets it ever created your resources — a subsequent
applywould try to create duplicates, anddestroycouldn't clean up. Never hand-edit state; never commit it to Git (it can contain secrets in plaintext); back it up (module 05 moves it to durable remote storage). - State can contain secrets. Resource attributes like generated
passwords or keys are stored in state in plaintext. This is the second
reason (after "it's machine-managed") never to commit
terraform.tfstateto Git — always.gitignoreit.
Drift: when reality and state disagree
Drift is when someone changes a Terraform-managed resource outside
Terraform — a colleague edits it in the portal, or an az command tweaks a
tag. Terraform detects drift during the refresh phase of plan: it queries
each resource's real current state, notices it no longer matches what's in
your config, and shows you a diff to bring it back. This is one of IaC's
superpowers — your config is the enforced source of truth, and drift shows
up as a plan that would "fix" the out-of-band change. You'll deliberately
cause and observe drift in the exercises.
The dependency lock file
terraform init writes .terraform.lock.hcl, which pins the exact
provider versions (and their checksums) selected under your version
constraints. Unlike state, this file should be committed to Git — it
guarantees everyone on the team (and your CI) uses byte-identical provider
versions, the same idea as a package-lock.json. Your ~> 4.0 constraint
says "any 4.x"; the lock file records "specifically 4.37.0" so builds are
reproducible.
Command reference
| Command | What it does | Example |
|---|---|---|
terraform init |
Downloads providers, configures backend, writes lock file | terraform init |
terraform plan |
Shows what apply would change; makes no changes | terraform plan |
terraform plan -out=tfplan |
Saves the plan to a file for a guaranteed-identical apply | terraform plan -out=tfplan |
terraform apply |
Applies changes after interactive confirmation | terraform apply |
terraform apply tfplan |
Applies a previously saved plan, no prompt | terraform apply tfplan |
terraform apply -auto-approve |
Applies without the yes prompt (dangerous; CI only) |
terraform apply -auto-approve |
terraform destroy |
Destroys everything in state after confirmation | terraform destroy |
terraform apply -refresh-only |
Reconciles state with real-world drift without changing config-driven resources | terraform apply -refresh-only |
terraform state list |
Lists resource addresses tracked in state | terraform state list |
terraform state show <addr> |
Shows all recorded attributes of one resource | terraform state show azurerm_resource_group.main |
terraform show |
Prints the full current state (or a saved plan) human-readably | terraform show |
terraform fmt |
Rewrites files to canonical formatting | terraform fmt |
Flag breakdown — terraform plan -out=tfplan:
-out=tfplan— writes the computed plan to a file namedtfplan.terraform apply tfplanthen applies exactly that plan with no re-computation and no prompt — the pattern used in CI (module 07) so the thing reviewed is exactly the thing applied.
Flag breakdown — terraform apply -refresh-only:
-refresh-only— updates state to match real-world drift (so state reflects reality) without trying to change resources back to match config. Use it to accept an out-of-band change, versus a normalapplywhich would revert the drift.
Hands-on exercises
Work in a fresh directory. These create real (but free) resources — a
resource group costs nothing, but you'll still destroy at the end to
practice the habit.
Set up the working directory. Create the folder and a
main.tf:mkdir -p ~/tf-labs/01-workflow && cd ~/tf-labs/01-workflowPut the provider skeleton from module 00 into
main.tf(theterraformandprovider "azurerm"blocks), then add a resource group below it:resource "azurerm_resource_group" "main" { name = "rg-tf-workflow" location = "eastus" }Make sure
ARM_SUBSCRIPTION_IDis exported (module 00, exercise 8):export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"Initialize and plan. Run:
terraform init terraform planVerify: the plan shows
azurerm_resource_group.mainwith a+(create) and ends withPlan: 1 to add, 0 to change, 0 to destroy.Read every line — this is the habit that saves you money.Apply and confirm in Azure. Run
terraform apply, review the same plan, and typeyes:terraform applyThen confirm the resource group really exists, using the CLI you already know:
az group show -n rg-tf-workflow -o tableVerify:
applyends withApply complete! Resources: 1 addedandaz group showfinds the group. Aterraform.tfstatefile now exists in your directory.Inspect the state. Look at what Terraform is now tracking:
terraform state list terraform state show azurerm_resource_group.mainVerify:
state listprintsazurerm_resource_group.main, andstate showprints its recorded attributes including the full Azure resourceid. This mapping is Terraform's memory.Prove idempotency. Run
terraform planagain without changing anything.Verify:
No changes. Your infrastructure matches the configuration.Desired state already equals current state, so there's nothing to do — the defining property of declarative IaC.Make a change and watch an in-place update. Add a tag to the resource group block:
resource "azurerm_resource_group" "main" { name = "rg-tf-workflow" location = "eastus" tags = { environment = "learning" } }Run
terraform plan, thenterraform apply.Verify: the plan shows
~(update in place) for the resource group andPlan: 0 to add, 1 to change, 0 to destroy.— not a destroy-and-recreate. Tags update in place.Diagnose and fix: drift from an out-of-band change. Simulate a colleague editing the resource in the portal by using
azto add a tag Terraform doesn't know about:az group update -n rg-tf-workflow --set tags.owner=someone-elseNow run
terraform plan.Observe: Terraform detects the drift during refresh and shows a plan that would remove the
ownertag (because your config doesn't mention it) — reasserting your config as the source of truth. You have two correct fixes depending on intent:- Revert the drift (config wins): run
terraform applyand let it remove theownertag. - Accept the drift (adopt it): add
owner = "someone-else"to your config'stagsblock, thenterraform planshows no changes — reality and config now agree.
Do the revert path here so you've seen Terraform enforce config.
- Revert the drift (config wins): run
Diagnose and fix: an unexpected destroy-and-recreate. Some attributes can't be changed in place — changing them forces replacement. Change the resource group's
location:location = "westus"Run
terraform plan.Observe: the plan now shows
-/+ destroy and recreateandPlan: 1 to add, 1 to change... 1 to destroy(look for the# forces replacementannotation on thelocationline). A resource group's location is immutable, so Terraform would delete and re-create it. This is exactly the plan you must never blindlyyes. Fix it by revertinglocationback toeastusand confirmterraform planshows no changes.Look at the lock file. Open
.terraform.lock.hcl:cat .terraform.lock.hclVerify: it pins an exact
azurermversion and checksums. Note this is the file you commit to Git — unliketerraform.tfstate, which you never commit.Create a
.gitignoreand clean up. Protect yourself from ever committing state, then destroy everything:printf '*.tfstate\n*.tfstate.*\n.terraform/\n*.tfplan\n' > .gitignore terraform destroyReview the destroy plan (
Plan: 0 to add, 0 to change, 1 to destroy), typeyes, then confirm it's gone:az group show -n rg-tf-workflow -o tableVerify:
destroyreportsDestroy complete! Resources: 1 destroyedandaz group shownow errors withResourceGroupNotFound. Nothing is left billing.
Independent challenge
Starting from an empty directory and without copying exercise commands,
create a config with two resource groups (in different regions), apply
it, then use only state-inspection commands to answer: how many objects is
Terraform tracking, and what are their full Azure resource IDs? Then make a
single change that causes a plan of exactly "1 to change" (not add, not
destroy, not replace), verify the plan matches that expectation before
applying, and finally destroy everything and prove with an az command
that both groups are gone. This draws on this module's workflow, state
inspection, and plan-reading, plus module 00's provider setup.
Stuck? One hint
To get a clean "1 to change" plan, modify an attribute that updates
in place rather than one that forces replacement — a tags change is
in-place (~), a location change is a replacement (-/+). Use
terraform state list to count tracked objects and
terraform state show <address> (or terraform show) to read each one's
id.
Common mistakes & troubleshooting
- Typing
yeswithout reading the plan. The entire safety model of Terraform is the plan. If you auto-approve habitually, you will eventually destroy-and-recreate something important. Read the summary line every single time. - Committing
terraform.tfstateto Git. State can contain secrets in plaintext and is machine-managed — never commit it. Add a.gitignorebefore your first commit (exercise 10). - Hand-editing
terraform.tfstate. It's valid JSON, which tempts people, but a wrong edit desynchronizes state from reality and can cause Terraform to destroy or duplicate resources. Useterraform statesubcommands (module 04+) instead of a text editor. - Losing state entirely. With default local state, deleting the file (or losing the machine) means Terraform forgets everything it manages. This is exactly why teams use remote state (module 05).
- Ignoring
-/+in a plan. A destroy-and-recreate on a database, a public IP, or anything stateful can mean data loss or a changed IP address. Always investigate what's forcing replacement before applying. - Cost pitfall: a resource group is free, but you
destroyed anyway to build the habit. From module 06 on, the resources are billable — the sameterraform destroydiscipline is what keeps an idle AKS cluster from running up a bill over a weekend.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
- What does each of
init,plan,apply,destroydo, in one phrase each? - What does the plan symbol
-/+mean, and why should it make you stop and look? - What is the state file, and what mapping does it hold?
- Give two reasons you must never commit
terraform.tfstateto Git. - What is drift, and how does Terraform detect it?
- You changed a resource in the portal by hand and want Terraform to accept that change rather than revert it. What do you do?
- Which file should you commit —
.terraform.lock.hclorterraform.tfstate— and why that one and not the other? - A plan shows
Plan: 1 to add, 0 to change, 1 to destroyfor a resource you only meant to modify. What has probably happened?
Show answers
init: download providers / set up backend + lock file.plan: show what would change without changing anything.apply: make the changes after confirmation and record them in state.destroy: delete everything tracked in state.- Destroy-and-recreate (replacement): Terraform will delete the resource and make a new one because an immutable attribute changed. It can mean data loss or a changed IP/identity, so never apply it blindly.
- A JSON file mapping each resource address in your config to the real Azure object (its resource ID and current attribute values) — Terraform's memory of what it manages.
- It can contain secrets (passwords, keys) in plaintext, and it's machine-managed state that doesn't belong under manual version control; committing it risks leaking secrets and causing merge conflicts on a file you must never hand-edit.
- Drift is a Terraform-managed resource being changed outside Terraform.
Terraform detects it during the refresh phase of
planby querying each resource's real current state and diffing it against config. - Add the changed attribute to your config so config matches reality (or
use
terraform apply -refresh-onlyto record the current real state) — thenplanshows no changes. A plainapplywould instead revert it. .terraform.lock.hcl— it pins exact provider versions/checksums so the team and CI build reproducibly, and it's safe to share.terraform.tfstatemust not be committed (secrets, machine-managed).- An attribute that forces replacement (an immutable field like a
resource group's
locationor a resource'sname) was changed, turning your intended in-place update into a destroy-and-recreate.
Next
02 — Providers, resources & the azurerm provider: go deeper on resource blocks, how resources reference each other, and how version pinning keeps your infrastructure reproducible.