This is a reference workflow for small teams using Terraform. It is deliberately conservative because a state problem can turn an ordinary change into resource replacement, drift, or an outage. The backend can vary, but the required properties do not: shared storage, locking, encryption, version history, restricted access, and a tested recovery path.
Keep state remote and narrowly scoped.
I avoid one state file for an entire organization. Separate states for environments and major infrastructure boundaries reduce blast radius, shorten plans, and make ownership clearer. They also avoid giving every pipeline permission to modify every resource.
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "example-platform-tf-state"
key = "production/network/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
The backend bucket should be provisioned separately, with versioning, encryption, public access blocked, retention appropriate to the organization, and access through short-lived identities. Backend credentials do not belong in Terraform files.
State often contains values that are sensitive even when the configuration marks them as sensitive. That flag hides values in some output; it does not remove them from state. State access should therefore be treated similarly to infrastructure access.
Plan once, review once, apply the same artifact.
terraform init -input=false
terraform fmt -check -recursive
terraform validate
terraform plan -input=false -out=tfplan
terraform show -no-color tfplan
The pipeline should publish a readable plan for review and apply the saved plan only after approval. Re-running terraform plan immediately before apply can produce a different change from the one reviewed. Plan artifacts can also contain sensitive values, so I keep them access-controlled and short-lived.
For automated checks I sometimes use -detailed-exitcode: zero means no changes, one means an error, and two means a valid plan with changes. A pipeline that treats every non-zero code as failure mishandles the last case.
Make refactors explicit.
Renaming a resource address without telling Terraform can look like “destroy the old object and create a new one.” A moved block records intent and keeps the change reviewable:
moved {
from = aws_s3_bucket.logs
to = module.audit.aws_s3_bucket.logs
}
For existing infrastructure that is not yet managed, I prefer declarative import blocks when the Terraform version supports them:
import {
to = aws_s3_bucket.archive
id = "company-production-archive"
}
Both approaches make the migration visible in code and in the plan. Direct terraform state mv and state rm still have legitimate uses, but I reserve them for controlled recovery or migration work with a backup and a second reviewer.
Prepare recovery before needing it.
terraform state pull > terraform-state-backup.json
terraform plan -refresh-only
The backup contains sensitive infrastructure data, so it must be encrypted, access-restricted, and removed according to the recovery procedure. Backend object versioning remains the primary safety net.
I do not manually edit JSON state. If a lock remains after a confirmed failed operation, I first verify that no apply is running, identify the lock owner, and only then consider terraform force-unlock. Removing an active lock can allow two writers to corrupt state.
Finally, I test recovery in a sandbox: restore a previous backend version, run a refresh-only plan, and confirm the documented procedure works. State safety is not one backend setting; it is a chain of storage controls, serialized writes, reviewed changes, explicit migrations, and recovery practice.