Terraform State Management
Migrate Terraform from local to S3+DynamoDB remote state, enable locking, and master state surgery commands to handle drift and refactors safely.
Before you start
- ▸Terraform 1.5 or later installed and on PATH
- ▸AWS CLI configured with credentials that have S3 (s3:GetObject, s3:PutObject, s3:ListBucket) and DynamoDB (dynamodb:GetItem, dynamodb:PutItem, dynamodb:DeleteItem) permissions
- ▸An existing Terraform root module with at least one resource defined
- ▸Basic familiarity with HCL syntax and the terraform init/plan/apply workflow
Terraform's state file is the source of truth that maps your configuration to real infrastructure. Lose it, corrupt it, or let two engineers write to it simultaneously and you will have a bad day. This guide covers the full lifecycle: local state, migrating to a remote backend with S3 and DynamoDB, state locking, and the surgical commands you need when reality drifts from what Terraform thinks it knows.
Local State vs Remote State
By default Terraform writes state to terraform.tfstate in your working directory. That works on a laptop for experiments. It fails in a team because:
- Two people running
terraform applyconcurrently will corrupt the file. - The file contains secrets in plaintext — it should never live in a git repository.
- CI/CD pipelines have no persistent local filesystem between runs.
Remote state solves all three. The file lives in a shared, versioned store (S3, GCS, Terraform Cloud, etc.) and a lock prevents concurrent writes. The S3 + DynamoDB backend is the most common open-source choice on AWS.
Prerequisites
- Terraform 1.5+ installed (
terraform versionto confirm). - AWS CLI configured with credentials that have S3 and DynamoDB permissions.
- An existing Terraform project, even a minimal one with a single resource.
Step 1 — Create the S3 Bucket and DynamoDB Table
Provision these resources outside your main Terraform project — either by hand or in a dedicated bootstrap workspace. You cannot store the state for the resources that store your state in those same resources.
aws s3api create-bucket \
--bucket my-tf-state-prod \
--region us-east-1
aws s3api put-bucket-versioning \
--bucket my-tf-state-prod \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket my-tf-state-prod \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms"
}
}]
}'
aws s3api put-public-access-block \
--bucket my-tf-state-prod \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
aws dynamodb create-table \
--table-name tf-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
Versioning on the bucket is critical — it lets you roll back to a previous known-good state if something goes wrong.
Step 2 — Configure the S3 Backend
Add a backend block to your root module. The key is the path inside the bucket; use a naming scheme that encodes environment and project so multiple workspaces share one bucket safely.
cat > backend.tf <<'EOF'
terraform {
backend "s3" {
bucket = "my-tf-state-prod"
key = "prod/myapp/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "tf-state-lock"
}
}
EOF
Then initialise. Terraform detects that the backend has changed and asks whether to copy the existing local state to S3.
terraform init -migrate-state
Answer yes at the prompt. Terraform uploads the local state, then writes a tiny .terraform/terraform.tfstate file recording which backend is in use. Commit that file; it is not sensitive.
Step 3 — Understand State Locking
Every plan, apply, or destroy acquires a lock by writing an item to DynamoDB with the key my-tf-state-prod/prod/myapp/terraform.tfstate. A second operator running the same command gets a clear error instead of a silent race.
# Deliberately inspect the lock table while an apply is running:
aws dynamodb scan --table-name tf-state-lock --region us-east-1
Output will show an item with LockID, Operation, Who, and Created fields while the lock is held. If a process dies mid-apply the lock will remain. Force-unlock only after confirming no apply is actually running:
# Get the lock ID from the error message or from the DynamoDB scan above
terraform force-unlock LOCK-ID-HERE
Never force-unlock while an apply is genuinely in progress — you will produce a corrupted or split-brain state.
Step 4 — State Surgery Commands
Over time resources get renamed in config, imported manually, or need to be moved between state files. The following commands let you operate on state directly. Always take a snapshot first:
terraform state pull > state-backup-$(date +%Y%m%d%H%M%S).tfstate
List and Show Resources
# List every resource address in state
terraform state list
# Inspect a specific resource
terraform state show aws_instance.web
Rename a Resource (mv)
When you rename a resource block in HCL, Terraform would otherwise destroy and recreate it. Instead, rename it in state first, then change the HCL:
terraform state mv aws_instance.web aws_instance.web_server
Move a Resource to Another State File
Splitting a monolith into smaller workspaces requires moving resources between state files. Use the -state-out flag:
terraform state mv \
-state-out=../networking/terraform.tfstate \
aws_vpc.main \
aws_vpc.main
Import Existing Infrastructure
A resource was created manually or by another tool and needs to be adopted:
terraform import aws_s3_bucket.logs my-existing-log-bucket
Write the matching HCL before importing, or terraform plan will immediately want to delete the resource.
Remove a Resource from State Without Destroying It
# Remove from state tracking but leave the real resource intact
terraform state rm aws_iam_role.legacy
Push a Repaired State
If you edited a state file manually to fix corruption (last resort), push it back:
terraform state push repaired.tfstate
Terraform validates the serial number and lineage before accepting it. Add -force only if you are certain you are not overwriting newer state.
Step 5 — Detecting and Handling Drift
Drift occurs when someone modifies infrastructure outside Terraform — via the AWS console, a script, or another tool. Terraform's state no longer reflects reality.
# Refresh state against real infrastructure (read-only, no changes applied)
terraform plan -refresh-only
A non-empty diff here means drift exists. Review the output carefully. If the drift is intentional and you want to accept it into state:
terraform apply -refresh-only
If the drift is unintentional and you want to revert reality to match code, run a normal terraform apply. For individual resources, target them to minimise blast radius:
terraform apply -target=aws_security_group.web
Using -target in production is a workaround, not a workflow. Always follow it with an untargeted plan to make sure the overall state is consistent.
Verification
After migrating to remote state, verify the following:
# Confirm backend is remote
terraform state list # must succeed without a local tfstate file
# Confirm the object exists in S3
aws s3 ls s3://my-tf-state-prod/prod/myapp/
# Confirm versioning is tracking history
aws s3api list-object-versions \
--bucket my-tf-state-prod \
--prefix prod/myapp/terraform.tfstate
Troubleshooting
Error: Failed to get existing workspaces
The IAM credentials lack s3:ListBucket on the bucket. Add that permission; s3:GetObject and s3:PutObject alone are not sufficient.
State lock not released after a crash
Check the DynamoDB table with aws dynamodb scan, confirm the apply PID is gone, then run terraform force-unlock with the ID from the lock item.
Serial conflict after manual push
Terraform increments a serial number with every write. If you push a state with an old serial you will see "cannot overwrite newer state". Pull the current state, merge your change, increment the serial manually, and push with -force only if you fully understand the diff.
Sensitive values appearing in plan output
State contains full resource attributes. Ensure the S3 bucket has KMS encryption and a restrictive bucket policy. Consider enabling sensitive = true on output values to suppress them from CLI output, and restrict who can call terraform state pull.
Frequently asked questions
- Can I store state for multiple environments in one S3 bucket?
- Yes. Use a different key per environment, for example prod/myapp/terraform.tfstate and staging/myapp/terraform.tfstate. Combine this with IAM prefix-based policies to enforce environment isolation.
- What happens if two developers run terraform apply at the same time?
- The second apply will immediately fail with a lock error showing who holds the lock and when they acquired it. Neither apply is corrupted. The second developer must wait or investigate why the first lock has not been released.
- Is terraform state mv safe to run against a remote backend?
- Yes, it acquires the lock before writing. However, always pull a backup first because mv is not transactional — if it is interrupted partway through a multi-resource move you may need to repair state manually.
- How do I recover if the S3 state object is deleted?
- If bucket versioning is enabled, restore the previous version with aws s3api restore-object or by using the S3 console to revert to the latest non-delete version. This is why enabling versioning before storing any state is non-negotiable.
- Should I commit terraform.tfstate to git as a backup?
- No. The file contains plaintext secrets and resource attributes. With S3 versioning enabled you already have a full audit trail. Add terraform.tfstate and terraform.tfstate.backup to .gitignore unconditionally.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.