Terraform keeps a state file that maps the resources in your code to the real resources it created (servers, DNS records, buckets). Every plan compares your code, the state and reality. Lose or corrupt the state, and Terraform no longer knows what it manages. Two people applying at once can create conflicting changes. Handling state properly is the most important operational part of Terraform.
Basics: Terraform basics. Most of this applies equally to OpenTofu. See OpenTofu.
What’s in the state
terraform state list
terraform state show cloudflare_dns_record.www
The state (terraform.tfstate, JSON) contains resource IDs, attributes, dependencies, and output values, often including sensitive data: generated passwords, private keys, database connection strings. Treat the state file as a secret.
Why local state is risky
By default, the state is a local file in the working directory:
- lost if the laptop dies, or the directory is deleted;
- not shared: a teammate running Terraform has a different (or no) state, and creates duplicates;
- no locking: two concurrent applies can corrupt it;
- easy to commit to Git by accident, with its secrets. Add
*.tfstate*to.gitignore.
Local state is fine only for personal experiments.
Remote backend on S3-compatible storage
Store state in a bucket (AWS S3, MinIO, Backblaze B2, Cloudflare R2, Wasabi):
terraform {
backend "s3" {
bucket = "tf-state"
key = "prod/network.tfstate"
region = "us-east-1"
# S3-compatible (non-AWS) endpoints:
endpoints = { s3 = "https://s3.example-provider.com" }
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
use_path_style = true
use_lockfile = true # S3-native locking (Terraform 1.10+)
}
}
export AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=…
terraform init -migrate-state # moves existing local state into the bucket
- Enable bucket versioning, so you can recover earlier state versions.
- Encrypt at rest, and restrict access to the people and CI jobs that run Terraform.
- Self-hosted MinIO on a separate server works well. See MinIO.
Other backends: Terraform Cloud/HCP, GitLab-managed state (HTTP backend), PostgreSQL (pg backend), Consul.
Locking
Locking prevents two apply runs at once. With use_lockfile = true, Terraform creates a .tflock object next to the state (older setups used a DynamoDB table on AWS). If a run crashes and leaves a stale lock:
terraform force-unlock <LOCK_ID>
Only do that when you’re sure no other run is active.
Organise state
- Split state by environment and component (
prod/network,prod/servers,staging/…), using different keys or directories. Smaller states mean faster plans and a smaller blast radius. - Share outputs between states with
terraform_remote_statedata sources, or by passing values explicitly. - Workspaces (
terraform workspace new staging) give separate states for the same code. Many teams prefer separate directories for clarity.
State commands for refactoring
terraform state mv aws_instance.web aws_instance.app # renamed resource in code
terraform state rm cloudflare_dns_record.old # stop managing (doesn't delete the real resource)
terraform import cloudflare_dns_record.www <zone_id>/<record_id> # adopt an existing resource
terraform plan # always verify: expect "no changes"
Modern Terraform also supports declarative moved {} and import {} blocks in code, which are reviewable in pull requests.
Drift
If someone changes infrastructure by hand, terraform plan shows the difference. Decide whether to update the code or let Terraform revert it. terraform apply -refresh-only updates the state to match reality, without changing resources.
Quick reference
| Task | Command / setting |
|---|---|
| List resources | terraform state list |
| Remote state | backend "s3" { … } + terraform init -migrate-state |
| Locking | use_lockfile = true |
| Unlock stale lock | terraform force-unlock <id> |
| Rename | terraform state mv / moved {} |
| Adopt existing | terraform import / import {} |
| Never | Commit *.tfstate to Git |
Related: Terraform modules and secrets management.