Why State Locking Breaks More Often Than You Think
Every Terraform tutorial tells you the same thing: put your state in an S3 backend, add a DynamoDB table for locking, done. That advice is correct, but it stops right where the real problems start. Locking works fine in the happy path. It gets ugly the moment a CI job dies mid-apply, two pipeline triggers fire within seconds of each other, or someone hits cancel on a running build.
The lock mechanism itself is simple. When Terraform starts an operation that modifies state, it writes an item to your DynamoDB table using the state file's path as the LockID. Any other Terraform process trying to run against that same state checks for that item first. If it exists, Terraform refuses to proceed and tells you who holds the lock, when they acquired it, and from what machine. That last part matters more than people realize once you're debugging a stuck pipeline at 2am.
The Failure Mode Nobody's Buildspec Handles
Here's the scenario that catches teams off guard. A CodeBuild job runs terraform apply, acquires the lock, and starts provisioning resources. Then the build times out, someone force-stops it in the console, or the underlying compute gets reclaimed. Terraform never gets to run its deferred unlock logic. The DynamoDB item just sits there.
The next pipeline run - triggered by a new commit, a scheduled job, or a teammate pushing an unrelated change - hits that lock immediately and fails with an error like:
Error: Error acquiring the state lock
Lock Info:
ID: 7e8f3a21-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Path: my-app/prod/terraform.tfstate
Operation: OperationTypeApply
Who: codebuild@ip-10-0-1-42
Created: 2024-03-11 14:22:07 UTC
At this point a lot of engineers panic and reach straight for terraform force-unlock. Don't do that yet. First check whether the process that created the lock is actually still running somewhere. If your CodeBuild job genuinely crashed and there's no active apply in flight, force-unlocking is safe. If another build is still mid-apply and just slow, force-unlocking while it's running is how you get two processes writing to the same state file simultaneously - which corrupts state far worse than a stuck lock ever would.
Diagnosing Before You Force-Unlock
Build this check into your recovery process instead of guessing:
- Check CodeBuild build history for the project and confirm no build is currently in the "IN_PROGRESS" state for that environment
- Look at the
Whofield in the lock error - it usually includes a hostname or container ID you can cross-reference against active build logs - Check the
Createdtimestamp - if it's older than your longest expected apply duration (say, 30+ minutes for a typical infrastructure change), it's almost certainly stale - Query the DynamoDB table directly with
aws dynamodb get-itemon the LockID to see the raw lock metadata without going through Terraform
Once you've confirmed the lock is genuinely orphaned, run:
terraform force-unlock <LOCK_ID>
Always pass the specific lock ID rather than relying on interactive confirmation in a script. And never wire force-unlock into an automated pipeline step that runs unconditionally - that defeats the entire purpose of locking. It should be a deliberate, logged, human-triggered action.
Concurrent Pipelines Fighting Over the Same State
The second failure mode is more subtle: it's not a crash, it's a design problem. If your CodeBuild project can be triggered by multiple sources - a CodeCommit push, a manual run, and a scheduled drift-detection job, for example - you can end up with two legitimate terraform plan or apply operations racing for the same lock at nearly the same moment. One wins, one waits or fails, and if your buildspec doesn't retry gracefully, you get flaky pipeline failures that look random but are entirely predictable once you understand the trigger overlap.
The fix here isn't more locking, it's fewer simultaneous entry points. Use CodeBuild's concurrent build limits on the project, or add a queuing mechanism upstream so only one execution per state path runs at a time. If you're using a different CI system entirely, the same principle applies - if you're figuring out how to structure a CI/CD pipeline for the first time, building in this kind of serialization from day one saves you from chasing race conditions later.
Buildspec-Level Retry Logic
A reasonable middle ground is adding retry logic with backoff directly in your buildspec, rather than failing the build outright on the first lock conflict:
build:
commands:
- |
for i in 1 2 3 4 5; do
terraform apply -auto-approve -input=false && break
echo "Lock conflict, retrying in 30s (attempt $i)"
sleep 30
done
This handles transient conflicts - two builds landing within seconds of each other - without masking a genuinely stuck lock. Cap the retries. If it's still failing after five attempts with 30-second gaps, that's a signal for a human to check DynamoDB directly rather than something a script should keep silently retrying forever.
State Corruption: The Scenario Locking Doesn't Fully Prevent
Locking protects against concurrent writes, but it doesn't protect you from a build that partially applies changes and then crashes before writing the final state. You can end up with real AWS resources that exist but aren't reflected in the state file that Terraform thinks is current. This is a different problem from a stuck lock, and force-unlocking won't fix it.
This is exactly why S3 versioning matters as much as the DynamoDB table. When you suspect the state file itself is inconsistent with real infrastructure, don't just force-unlock and re-run apply blindly. Pull the last few versions of the state object from S3, diff them, and run terraform plan against the suspect version before applying anything. If the plan shows it wants to destroy and recreate half your infrastructure, that's your signal the state drifted, not that the lock was the problem.
Building a Recovery Runbook Instead of Reacting Each Time
Most teams treat stuck locks as one-off incidents and firefight each time from scratch. Write down the actual steps once:
- Confirm no active build is running against the environment (CodeBuild console or CLI)
- Pull the lock item from DynamoDB and check the timestamp against expected apply duration
- If stale, run
force-unlockwith the specific lock ID, logged with who ran it and why - Immediately run
terraform plan(not apply) to check for drift before doing anything else - Only then proceed with apply, and watch the build to completion
Putting this in a runbook means the next person who hits it - and they will - isn't guessing under pressure.
Preventing Recurrence at the Pipeline Level
A few structural changes cut down how often you hit this in the first place. Set explicit timeouts on your CodeBuild projects so a hung apply fails fast instead of running until it's manually killed. Restrict who can cancel a running build mid-apply, since that's one of the most common ways locks get orphaned. And separate your plan and apply stages into distinct pipeline steps with a manual approval gate in between - this shrinks the window where a lock is held during unattended automation, and gives a human a natural checkpoint to notice something's wrong before it becomes a stuck lock at all.
None of this replaces the S3 plus DynamoDB backend setup - that part of the standard advice is still correct and still necessary. It just isn't sufficient on its own. The locking mechanism tells you when there's a conflict; it's on your pipeline design to make sure conflicts are rare and recoverable when they happen anyway.