Terraform State Corruption at 3 AM: How We Recovered and What We Changed
A stale lock, a partial apply, and a state file that no longer matched reality. Here's the recovery playbook we now hand every new engineer — and the guardrails that make sure we never do it live again.

It was a Tuesday, 03:14 UTC, and our Terraform apply had been running for eleven minutes when the runner crashed. When the on-call engineer re-ran it, Terraform told him the state was locked. When he forced the unlock — which, yes, we'll get to — the next plan wanted to destroy the production RDS cluster. That's the story. Here's what we learned.
What actually broke
We run our infra on AWS with an S3 backend for Terraform state and DynamoDB for state locking. Standard setup, been fine for years. The apply in question was a routine change: adding two IAM policies and rotating a KMS key alias. Nothing exotic.
What happened, in order:
- GitHub Actions runner spun up, acquired the DynamoDB lock, started the apply.
- Roughly nine minutes in, the runner hit its 10-minute idle timeout on a spot instance and was reclaimed mid-apply.
- The
terraform applyprocess was killed before it could write the updated state back to S3. - The DynamoDB lock, however, was still held — the process never got to release it.
- On-call ran
terraform force-unlock <LOCK_ID>and re-ran the plan. - Terraform read the pre-apply state from S3, compared it to reality (which had drifted because half the apply had gone through), and produced a plan that included destroying resources it thought were duplicates.
The engineer caught it. Barely. The plan output had a - next to aws_db_instance.primary and he stopped before typing yes. That's the only reason this is a blog post and not a much longer incident report.
Why the lock wasn't the real problem
Everyone's first instinct after this kind of near-miss is "the lock system failed". It didn't. DynamoDB did exactly what it was told. The problem is that Terraform's locking protects the state file, not the real infrastructure. If your process dies after mutating cloud resources but before writing state, you have drift, and no lock in the world fixes that.
A Terraform state lock is a mutex for the state file. It is not a transaction across your cloud provider. Treat it that way and you'll stop being surprised.
The recovery, step by step
We didn't recover cleanly on the first try. We recovered on the third. Here's the sequence that finally worked, sanitised.
1. Freeze everything
First move: disable the CI workflow that runs Terraform. Not pause, not comment out — disable in the GitHub UI so nobody can trigger it by re-running a failed job. Post in the incident channel that IaC is frozen. This buys you time to think without a colleague in another timezone "just trying something".
2. Snapshot the state before you touch it
S3 versioning was on (please, please turn this on if it isn't), so we had every historical version of the state file. We pulled the last known-good version and the current broken version to local disk:
# Pull current (post-crash) state
aws s3api get-object \
--bucket tf-state-prod \
--key env/prod/terraform.tfstate \
./state.broken.json
# List versions and pull the last known-good
aws s3api list-object-versions \
--bucket tf-state-prod \
--prefix env/prod/terraform.tfstate
aws s3api get-object \
--bucket tf-state-prod \
--key env/prod/terraform.tfstate \
--version-id <VERSION_ID_BEFORE_APPLY> \
./state.lastgood.json
Both files went into a private incident bucket with a timestamp. Do not skip this. If your next command corrupts things further, this is your rollback.
3. Reconcile state with reality, not the other way around
The temptation is to restore state.lastgood.json and re-apply. Don't. The last-good state doesn't know about the IAM policies that did get created during the partial apply. Restoring it means Terraform will try to create them again, hit "already exists" errors, and you'll be doing surgery under pressure.
Instead we did this:
- Restored
state.lastgood.jsonas the active state. - For every resource in the changed module, ran
terraform plan -target=<resource>to see what Terraform thought vs. what actually existed in AWS. - For resources that already existed in AWS but weren't in state, used
terraform importto bring them in. - For resources in state but not in AWS, used
terraform state rmto drop them.
This took about forty minutes for a module with roughly a dozen resources. Slow, deliberate, -target flags everywhere, one resource at a time.
4. Run a full plan against production and read every line
Once reconciled, we ran a plan against the whole environment — not just the affected module — and read the entire diff out loud on a call. Two engineers. If either of us saw a destroy we weren't expecting, we'd stop. The final plan showed zero changes. That's when we knew we were back.
What we changed afterwards
Recovery is the boring part. The interesting part is what stops this happening again.
Kill the spot runner for apply jobs
We were running Terraform apply on spot GitHub runners because they were cheap. The savings, in our case, were somewhere in the tens of dollars a month. The near-miss cost us hours of senior engineer time and a lot of trust. Apply jobs now run on on-demand runners with a hard 45-minute timeout and no idle-eviction. Plan jobs can stay on spot — they're read-only.
Split plan and apply into separate jobs with an artifact hand-off
We now generate the plan file explicitly, upload it as an artifact, and the apply job downloads and applies that specific plan. If the apply job dies, the plan is still on disk and we can inspect exactly what was in-flight.
- name: Terraform Plan
run: |
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
- name: Upload plan
uses: actions/upload-artifact@v4
with:
name: tfplan-${{ github.run_id }}
path: |
tfplan.binary
tfplan.json
retention-days: 30
Add a drift detector that runs on a schedule
Once a day, a job runs terraform plan -detailed-exitcode against every environment and posts to Slack if the exit code is 2 (drift detected). This would have caught the partial apply within 24 hours even if the on-call had gone home. In our experience, catching drift on a daily cadence dramatically reduces the blast radius when something does go wrong.
Require a human in the loop for destroys
We added an OPA policy in our CI pipeline that fails any plan containing a destroy action on a resource type in an allow-list (RDS, DynamoDB tables, S3 buckets, EKS clusters, KMS keys). It can be overridden with a signed commit message, but the default is: no, you cannot destroy stateful infrastructure without a human explicitly saying so.
Stop using force-unlock as a reflex
The real mistake in the incident wasn't the runner dying — that's going to happen. It was force-unlocking without first asking "why is this locked?". We now have a runbook that says: if you see a stale lock, first check whether the associated CI job is actually still running. If it isn't, pull the state file, diff it against the last version, and only then consider unlocking.
The bit nobody talks about
Terraform's failure modes are boring until they aren't. Ninety-nine applies out of a hundred will complete cleanly, and you'll build up the intuition that state and reality are the same thing. They aren't. State is a cache of reality, updated on a best-effort basis, and any time your apply process is interrupted you should assume the cache is stale.
The teams I've seen handle this best treat every apply as potentially interruptible and design their pipelines around that assumption. The teams I've seen handle it worst are the ones with a Slack channel full of terraform force-unlock commands and no one asking why.
Where we'd start
If you inherit a Terraform setup tomorrow, do these four things in the first week, in this order: turn on S3 versioning for the state bucket, move apply jobs off spot/preemptible runners, split plan and apply with an artifact hand-off, and add a scheduled drift detector. That's maybe a day of work and it eliminates the top three ways state gets corrupted in the first place.
We write more about IaC pipelines and reliability work on the 72Technologies blog, and if you want a second pair of eyes on your Terraform setup before it bites you at 3 AM, that's part of what our DevOps engagements cover.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

OpenTelemetry Sampling in Production: The Config That Saved Our Trace Bill
Head sampling threw away the traces we needed. Tail sampling blew up our collector memory. Here's the sampling config we landed on after six months in production.
CloudFront to Vercel: The Cache Header Mismatch That Cost Us a Weekend
We fronted a Vercel app with CloudFront to satisfy a compliance requirement. Two weeks later, stale checkouts and missing Set-Cookie headers taught us how differently these two CDNs think about caching.
Vercel Edge Middleware Latency: What We Measured When We Moved Auth to the Edge
We moved auth checks from a Node API route to Vercel Edge Middleware expecting free speed. Some routes got faster, some got slower, and the bill moved in ways we didn't predict.
