Skip to content

Managing Terraform state in practice

The Terraform article on this site introduces state as the idea that sets Terraform apart from a task runner. That overview names the state file, the remote backend, and the terraform state subcommands in passing. This article goes deeper on the part that bites teams in production: where state lives, how locking stops two engineers corrupting it, how to bring existing devices under management, and how to fix state safely when it drifts from reality.

What the state file actually is

terraform.tfstate is a JSON document that maps every resource in your HCL to the real object it manages and stores bookkeeping metadata. It is how terraform plan can tell "nothing to do" from "create everything" — it compares your configuration, the recorded state, and the live infrastructure, then proposes only the delta.

Two properties make state operationally sensitive:

  • It can contain secrets in clear text. Marking a variable sensitive = true keeps its value out of plan/apply output and logs, but the resolved value can still land in the state file unencrypted. The IOS XE provider stores whatever it reads back, so a device password can end up in state even though your .tfvars was clean.
  • It must be shared, but only carefully. Everyone who runs apply needs the same state, yet it is the one file you must never commit to Git or hand-edit. Treat it as sensitive infrastructure, not source code.

That combination is why local state — the default single file on your laptop — only works for solo experiments. The moment a second engineer runs apply, you need a remote backend.

Remote backends and state locking

A remote backend stores state off your workstation — an S3-compatible bucket, an HTTP backend, or HCP Terraform (formerly Terraform Cloud) — so the team shares one authoritative copy. Crucially, a remote backend also provides state locking: before any operation that writes state (apply, state mv, state rm), Terraform acquires a lock and releases it when done. If a colleague is mid-apply, your command waits instead of racing them and corrupting the file.

For the S3 backend, locking historically required a separate DynamoDB table. Terraform 1.10 added native S3 locking via use_lockfile, which writes a small lock object alongside the state file and removes the need for DynamoDB (it began as an opt-in and the two mechanisms can run side by side during a migration):

terraform {
  backend "s3" {
    bucket       = "netauto-tfstate"
    key          = "iosxe/prod/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true   # encrypt the state object at rest
    use_lockfile = true   # native S3 state locking (Terraform 1.10+); no DynamoDB table needed
  }
}

After changing a backend block, run terraform init -migrate-state to move existing state into the new backend (or -reconfigure to point at it without copying).

A crashed run can leave a stale lock

If Terraform is killed mid-apply, the lock may not be released and the next run fails with "Error acquiring the state lock." Only after you have confirmed no apply is actually running should you clear it with terraform force-unlock <LOCK_ID>, using the ID from the error message. Forcing a lock while another apply is live is exactly how state gets corrupted.

Fixing state without corrupting it

Because state is plain JSON, it is tempting to open it and fix a stale entry by hand. Don't — hand-editing is the fastest way to desynchronise state from reality and break every future plan. Terraform ships state subcommands so you never have to:

$ terraform state list                 # every resource Terraform tracks
iosxe_vlan.sales
iosxe_vlan.voice

$ terraform state show iosxe_vlan.sales # inspect one object's recorded attributes

# Rename/move a resource in state after refactoring HCL — no destroy/recreate on the device
$ terraform state mv iosxe_vlan.voice iosxe_vlan.voice_net

# Stop managing a resource without deleting it from the device
$ terraform state rm iosxe_vlan.sales

state mv is what you reach for after renaming a resource block or moving it into a module — it updates Terraform's bookkeeping so the next plan is empty instead of proposing to destroy the old address and create a new one. state rm detaches a resource from Terraform while leaving the live configuration untouched.

Bringing existing devices under management

Most networks are brownfield: the VLANs and interfaces already exist, configured by hand long before Terraform arrived. Importing records those objects in state so Terraform manages them going forward rather than trying to recreate them.

The legacy terraform import <address> <id> command writes the resource into state but generates no configuration — you still have to hand-write a matching resource block, and the operation leaves no reviewable record. Terraform 1.5 added the declarative import block, which is the better choice for anything beyond a one-off fix: it lives in your configuration, shows up in plan before anything is written, and is version-controlled like the rest of your code.

import {
  to = iosxe_vlan.sales
  id = "10"            # the VLAN already exists on the device
}

Run terraform plan -generate-config-out=generated.tf and Terraform scaffolds a starting resource block for each imported object, which you then review and tidy. Once the import has applied cleanly you can remove the import block or keep it as a record of where the resource came from.

State as a drift and compliance signal

The same comparison that powers plan makes state a continuous audit of your network. If someone changes a managed VLAN out of band, the next terraform plan shows it as a diff to reconcile. Run that plan on a schedule in CI and a non-empty plan becomes a compliance alert: something changed the network outside the pipeline, and a human should investigate before anyone applies. This is the stateful half of the pull → render → deploy → verify loop described in driving automation from a source of truth, and it slots naturally into a CI/CD pipeline as a recurring prevalidation job.

Workspaces give one configuration several independent state files, so the same code can target dev, test, and prod. They isolate state — but not credentials. Two workspaces will still reach the same real device if their variables and provider configuration say so, so vary those per environment, and for strong separation many teams run entirely separate backends per environment rather than relying on workspaces alone.

Key takeaways

  • State maps HCL to real objects and can hold secrets in clear text — never commit terraform.tfstate, and encrypt it at rest in the backend.
  • A remote backend plus locking is mandatory for teams; locking prevents two concurrent applies from corrupting state. Clear a stale lock with force-unlock only when you are certain nothing is running.
  • Never hand-edit state — use terraform state list/show/mv/rm so Terraform's bookkeeping stays consistent.
  • Import brownfield devices with the import block (Terraform 1.5+), which is reviewable and can scaffold config via -generate-config-out, rather than the state-only import command.
  • A scheduled plan turns state into a drift detector — a non-empty plan means something changed outside the pipeline.

Sources: Terraform: state, Terraform: state locking, Terraform: S3 backend, Terraform: import, Terraform: the terraform state command, AWS Builder Center — S3 native state locking explained.