OnCallReady

Lesson 13.1 · Terraform: State & Modules · 21 min read

State: what it is, why it holds secrets, where it must live

In plain words

Think of a coat check at a theatre. You hand over your coat and get ticket number 42. The ticket does not hold the coat; it says which coat on the rack is yours. Lose the ticket and the attendant cannot tell your coat from a stranger's. And the attendant also writes a few notes on the stub, sometimes including what is in the pockets.

Terraform state is that set of tickets: azurerm_virtual_network.main points at /subscriptions/.../vnet-orders-dev. It also stores every attribute the provider returned, which includes passwords, keys and connection strings in plaintext, even for values marked sensitive. serial counts every write and lineage identifies which state this is. That is why state lives in a protected remote backend, never in git.

The problem

You applied a configuration in chapter 12 and Terraform created a resource group. You run terraform plan again and it says No changes. How does it know that the resource group in Azure is the one your block made, and not one a colleague created by hand with the same name?

It knows because it wrote that down. The notebook it writes in is called state, and this chapter is about it: what is in it, where it must live, how to change it safely, and (later) how to split code into reusable modules without Terraform losing track of what it owns.

What you need to know already:

Why there is a state file at all

Your configuration says what should exist. Azure says what does exist. Neither one says which real object belongs to which block.

That mapping is state: a JSON file Terraform writes after every apply. One line of the mapping looks like this:

azurerm_resource_group.main  ->  /subscriptions/.../resourceGroups/rg-platform-dev

On the left is the resource address from your code. On the right is the resource ID: the full path Azure uses to name one object. It starts with the subscription (the Azure account the bill goes to, written as a long ID), then the resource group, then the object.

Terraform keeps state for four reasons. The exam asks for them by name:

mapping       which real object each resource address manages
metadata      dependencies, so a DELETED block is still destroyed in the right order
performance   cached attributes, so a plan over 800 resources is not 800 cold reads
              (plan still re-reads by default; -refresh=false skips it)
syncing       one shared record when a team works on the same infrastructure

Two of these deserve a sentence each:

What is inside

terraform state pull prints the current state as JSON. It works wherever the state is stored (on your disk or in the cloud). With local state you can also just open the terraform.tfstate file.

Pipe it into jq to look at parts of it. del(.resources) means "show everything except the long resources list":

# in the anatomy mission's configuration (next step), after its apply
terraform state pull | jq 'del(.resources)'
{
  "version": 4,
  "terraform_version": "1.9.8",
  "serial": 7,
  "lineage": "3f2b1c9e-8d7a-4e6f-b5c4-a39281706f5e",
  "outputs": {
    "db_password": {
      "value": "Zq8#tR2vLp!mW4xN7cKe9sHd",
      "type": "string",
      "sensitive": true
    }
  },
  "check_results": null
}

What each top-level field means:

version            the state FORMAT version (4 since Terraform 0.12)
terraform_version  the Terraform that last wrote it; an older binary refuses to read
                   state written by a newer one
serial             a counter, +1 on every write - how Terraform notices that a saved
                   plan or an upload is out of date
lineage            a random ID (a UUID) fixed when the state was first created - two
                   states with different lineages are unrelated, and Terraform will
                   not mix them
outputs            the outputs of your top-level code, values included (sensitive too)

Now the resources. .resources[0] is the first entry of the list:

# same configuration: its first resource
terraform state pull | jq '.resources[0]'
{
  "mode": "managed",
  "type": "azurerm_virtual_network",
  "name": "main",
  "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]",
  "instances": [
    {
      "schema_version": 0,
      "attributes": {
        "address_space": ["10.30.0.0/16"],
        "id": "/subscriptions/.../virtualNetworks/vnet-orders-dev",
        "location": "westeurope",
        "name": "vnet-orders-dev",
        "resource_group_name": "rg-orders-dev"
      },
      "sensitive_attributes": [],
      "dependencies": [
        "azurerm_resource_group.main"
      ]
    }
  ]
}

Reading it field by field:

Useful one-liners (each is plain jq from chapter 7):

terraform state pull | jq '.serial'
terraform state pull | jq -r '.resources[] | "\(.type).\(.name) \(.instances | length)"'
terraform state pull | jq -r '.resources[].instances[].attributes.id'
terraform state pull | jq '.resources[] | select(.type=="azurerm_subnet") | .instances[].index_key'

In order: the write counter; every address with how many instances it has; every Azure ID Terraform manages; the for_each keys of the subnets.

Secrets live in state, in plaintext

# same configuration: it has a random_password
jq -r '.resources[] | select(.type=="random_password") | .instances[0].attributes.result' terraform.tfstate
Zq8#tR2vLp!mW4xN7cKe9sHd

That password came from a random_password resource (a resource that just generates a random string and remembers it). Its output was marked sensitive = true.

sensitive only hides a value on screen (in plan and apply output). State stores the real value, because Terraform needs it to notice changes. The same goes for storage account access keys, connection strings (a database address plus user and password in one string) and every generated password.

So the state file is a secret, and you handle it like one:

Later (Ch 14): keeping secrets out of state in the first place, with a secret store and references instead of values.

Local state

With no backend configured (next lesson), state is a file next to the code:

terraform.tfstate          the current state
terraform.tfstate.backup   the previous version, written before every change

Every write first renames the old file to .backup. That gives you exactly one step of undo, on one machine.

For anything a colleague or a pipeline also runs, local state is wrong: nobody else sees it, nothing stops two runs at once, and a lost laptop means lost knowledge of what Terraform owns.

The three rules

  1. Never commit state. It contains secrets, and git does not stop two people applying from two checkouts at once - you get two different "truths".
  2. Never edit state by hand. Every change you would want has a command: state mv, state rm, import, or a moved / removed / import block (all later in this chapter). Hand edits break the serial and lineage checks and are the fastest way to a state Terraform cannot read.
  3. Never let two runs write it at once. Both read serial 41, both write serial 42, and the second write silently throws away the first. Preventing that is called locking (13.11).

Serial and lineage in practice

# a state that has been written 7 times, before and after one more apply
terraform state pull | jq .serial
7
terraform apply -auto-approve
...
terraform state pull | jq .serial
8

-auto-approve skips the "Enter a value: yes" prompt. The serial went up by one because the apply wrote state once.

Terraform checks the serial in two places you will meet:

Failed to write state: cannot import state with serial 5 over newer state with serial 8
Failed to write state: cannot import state with lineage "9a1f..." over unrelated state with lineage "3f2b..."

Both are safety nets against the classic accident: restoring an old copy over a newer truth.

One state per environment, and not too big

A state file is a blast radius: everything one bad apply can damage. Everything in it can be touched by one apply, every plan reads all of it, and everyone who can apply it can change all of it.

What you can now do:

Why it helps

Situations where this pays off: a security review asks whether your pipeline logs or artifacts expose secrets, and you know the state blob and plan files are the real exposure, not the console output. A colleague asks why terraform apply tfplan said "Saved plan is stale": the serial moved. Someone proposes committing terraform.tfstate "so we don't lose it", and you can explain why git is neither a lock nor a safe place. Someone restores an old backup and state push refuses because of serial or lineage, and you know that is the safety net. Interviewers ask "why does Terraform need state?" and "what is in it?" almost every time.

FAQ

Why does sensitive = true not keep a value out of state?

Terraform needs the real value to detect changes on the next plan, so the state stores every attribute exactly as the provider returned it. sensitive only affects what the CLI prints. A random_password result, a storage account key, a database connection string: all sit in the JSON. The fix is protecting the state, and keeping secrets out of Terraform where you can: the application reads them from a Key Vault itself, and programs sign in with managed identities instead of passwords.

What are serial and lineage?

serial increments on every state write; a saved plan records the serial it was made against and refuses to apply if state has moved on, and state push refuses an older serial over a newer one. lineage is a UUID fixed when the state was first created; two states with different lineages are unrelated, and Terraform refuses to mix them. Both protect against restoring an old or wrong copy over the truth.

Can I edit the state file by hand if I am careful?

Don't. Everything you would want to change has a command or a block: state mv / moved, state rm / removed, import, -replace, apply -refresh-only. Hand edits bypass serial and lineage guarantees, easily produce invalid JSON or mismatched schema versions, and are invisible to review. If you truly must, state pull to a file, edit, and state push, after stopping every pipeline and keeping a backup.

Is terraform.tfstate.backup a real backup?

Only a very small one. With local state, Terraform moves the previous version to .backup before every write, which gives exactly one step of undo on one machine. For shared state the real backups are blob versioning and soft delete on the storage account, plus a terraform state pull > backup.tfstate before any manual surgery.

Does plan read the whole state even if I changed one resource?

Yes. A plan loads the whole state and, by default, refreshes every object in it against the provider. That is why a huge state makes every plan slow and every change risky: one typo anywhere can appear in anyone's plan. It is one of the main arguments for splitting states by environment, lifecycle and ownership.

In an interview Junior

Why does Terraform need a state file, and why is it sensitive?

The configuration says what should exist and the cloud says what does exist, but neither says which real object belongs to which block. State is that mapping: each resource address to its resource ID. It also records dependencies (so a deleted block is still destroyed in the right order), caches attributes for performance, and tracks a serial (write counter) and lineage (identity) that protect against overwriting newer state with older.

It is sensitive because it holds every attribute the provider returned, in plain text - generated passwords, access keys, connection strings. sensitive = true only hides values on screen, not in state.

So: never commit it (.gitignore covers *.tfstate), keep it in a remote backend that is encrypted, versioned and access-controlled, and never edit it by hand. terraform state pull | jq is how you read it.

Also asked: What happens if two people run terraform apply at the same time without locking? · What is the difference between serial and lineage in a state file? · Why should you have one state per environment?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.