Terraform's state file is not a cache you can delete and rebuild. It is the only record mapping your configuration to real resource identifiers, and everything the tool does depends on it being accurate. Teams treat it as an implementation detail right up until two applies overlap and a production database is marked for replacement.
01What state actually holds
State maps each resource block in your configuration to a provider-specific identifier, plus the attributes Terraform last observed. Without it there is no way to know that aws_db_instance.primary means the specific database that exists rather than a new one to create.
It also holds outputs, dependency ordering and, critically, any attribute the provider returned - including generated passwords, connection strings and private keys. State is secret material. Anyone with read access to the file has read access to a meaningful share of your credentials, regardless of how carefully you handled variables.
02Local state is a single point of failure with no locking
A terraform.tfstate on someone's laptop means the infrastructure is only manageable from that laptop, and only by that person. It survives until the machine is replaced, and then you are reconstructing an inventory by hand against a live environment.
The remote backend fixes storage and, more importantly, adds locking. Two engineers running apply against the same state simultaneously produce interleaved writes: one apply reads state, the other writes it, and the first then overwrites with a version that does not know about the second's changes. The resources exist but state has forgotten them, so the next plan proposes recreating them.
- Use a remote backend with locking from the first commit, not once the team grows.
- Enable versioning on the backing store so you can roll back a corrupted write.
- Encrypt at rest and restrict read access as tightly as you would a password vault.
- Never commit state to git - it is both a secret and a merge conflict waiting to happen.
03One state file per blast radius
The default instinct is one state for the whole estate, which means every plan reads every resource and every apply can touch anything. A twenty-minute plan is annoying; an apply that can destroy the VPC because someone was editing a lambda is dangerous.
Split by lifecycle and by risk. Networking, data stores and long-lived shared infrastructure change rarely and catastrophically. Application resources change several times a day and are cheap to recreate. Putting them in the same state forces the slow, dangerous things through the same pipeline as the fast, safe ones.
| Layer | Change frequency | Blast radius | Who applies |
|---|---|---|---|
| Accounts, org, IAM baseline | Rarely | Total | Platform, reviewed |
| Network, DNS, shared VPC | Monthly | Very high | Platform, reviewed |
| Data stores | Occasionally | High - data loss | Platform, with approval gate |
| Application infrastructure | Daily | Service only | Service team, automated |
| Observability, dashboards | Frequently | Low | Anyone, automated |
04Workspaces are not environments
Terraform workspaces give you multiple state files against one configuration. That sounds like environments, and teams use them that way, but it means staging and production share a single configuration, a single backend and a single set of credentials in the pipeline. A mistake in the configuration reaches both.
Separate directories with separate backends and separate credentials give you real isolation. Production should be a distinct place with distinct permissions, not a value passed to terraform workspace select. Workspaces earn their keep for short-lived parallel copies of the same environment - ephemeral review stacks, for instance - not for the production boundary.
05Drift is normal, and how you respond decides whether it hurts
Someone will change something in the console during an incident. An autoscaler will adjust a value Terraform believes it owns. A provider will start returning an attribute it previously omitted. Drift is not a discipline failure; it is the steady state of any environment with humans and controllers in it.
The failure is discovering drift during an unrelated apply, when the plan proposes reverting an emergency fix nobody documented. Run plan on a schedule against every state and alert on non-empty output, so drift surfaces as a small notification on a Tuesday rather than as a surprise inside a deployment. Use ignore_changes deliberately for attributes another system legitimately owns, rather than fighting a controller in a loop.
06Recovering when state and reality disagree
The two operations that matter are import and state rm. Import binds an existing resource to a configuration block when state has lost it. state rm forgets a resource without destroying it, which is what you want when a resource has moved to another state file or is now managed elsewhere.
Both are surgical and both are easy to get wrong in a way that only shows up on the next apply. Take a copy of the state before touching it, do the operation, then run plan and confirm it is empty. An empty plan is the only acceptable evidence that state matches reality - anything else means you have not finished. And where the configuration is refactored rather than the infrastructure, prefer moved blocks in code over manual state mv, because they are reviewable and repeatable.
- Snapshot state before any manual state operation.
- Verify with a plan that produces no changes, every time.
- Prefer moved blocks over terraform state mv for refactors.
- Treat a destroy proposal on a stateful resource as a stop condition, not a diff to skim.
Topics
Marcus Hale
Lead Architect · SyncTrix
Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.
Building something like this?
SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.