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 = truekeeps its value out ofplan/applyoutput 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.tfvarswas clean. - It must be shared, but only carefully. Everyone who runs
applyneeds 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-unlockonly when you are certain nothing is running. - Never hand-edit state — use
terraform state list/show/mv/rmso Terraform's bookkeeping stays consistent. - Import brownfield devices with the
importblock (Terraform 1.5+), which is reviewable and can scaffold config via-generate-config-out, rather than the state-onlyimportcommand. - A scheduled
planturns 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.