Terraform S3 State Locking Without DynamoDB: use_lockfile

You can now do Terraform S3 state locking without DynamoDB by setting use_lockfile = true in the S3 backend. The backend writes a small lock object next to your state and uses S3 conditional writes to guarantee only one process holds it, so the separate DynamoDB table becomes optional and, per HashiCorp, deprecated. This works on Terraform 1.10.0 and later, and the safe way to switch is to run both mechanisms together for one transition, then remove DynamoDB once every runner is upgraded.
How S3 native locking actually works
The old pattern paired an S3 bucket for state with a DynamoDB table for locking. DynamoDB gave you a mutex (one writer at a time) and a consistency digest.
That gap is closed. Amazon S3 supports conditional writes: the If-None-Match header validates that no object with the same key already exists before the write commits. Terraform's S3 backend uses exactly this. When you enable locking, it attempts to create a lock object at <your-state-key>.tflock. If the object already exists, the conditional write fails, and Terraform knows someone else holds the lock. When the run finishes, it deletes the lock object.
So the lock is a real object in the same bucket as your state, subject to the same IAM, encryption and versioning. There is no second service to provision, pay for, or reason about during an outage.
The HashiCorp S3 backend documentation is explicit about the direction: use_lockfile enables S3-based locking, and DynamoDB-based locking is deprecated and will be removed in a future minor version. The dynamodb_table argument still works today, but it is on the way out.
What you gain and what you give up
The gain is fewer moving parts. One bucket, one set of permissions, one thing to audit. Bootstrapping a new account or a landing zone no longer needs a DynamoDB table before Terraform can run safely, which removes a chicken-and-egg step from account provisioning.
The thing to be honest about: the DynamoDB table also stored a digest of the state used for a consistency check. With native locking you rely on S3 itself for consistency. For the vast majority of teams that is fine. If you have tooling that reads the DynamoDB digest directly, that assumption breaks, and you should find it before you delete the table.
| DynamoDB locking (deprecated) | S3 native locking (use_lockfile) | |
|---|---|---|
| Extra AWS resource | DynamoDB table with LockID key | None, uses the state bucket |
| Terraform version | Any recent version | 1.10.0 and later |
| Lock mechanism | DynamoDB conditional put | S3 conditional write (If-None-Match) |
| IAM surface | S3 bucket plus DynamoDB table | S3 bucket only |
| Long-term support | Being removed | Recommended path |
What to check before you change anything
Do not edit the backend block first. Locking bugs show up as a stuck pipeline or, worse, two applies racing the same state. Check these first.
Every runner is on Terraform 1.10.0 or newer. This is the one that bites. If a laptop or an old CI image still runs an older Terraform, it does not understand use_lockfile and will fall back to whatever locking it knows. S3 native state locking is available since Terraform 1.10.0. Pin the version in CI and confirm what your engineers run locally.
Bucket versioning is on. Versioning is the recovery mechanism for state, and AWS guidance is to enable versioning so you can restore a previous known-good snapshot. Native locking does not change that, and you want it in place before you touch anything.
Your bucket is a general purpose bucket. Conditional writes are a general purpose bucket feature. If your state lives somewhere exotic or on an S3-compatible store, confirm it implements If-None-Match before you rely on it for locking.
The IAM policy will cover the lock object. The lock is a separate object, so read the permissions section below before switching, not after the first failed run.
The migration, step by step
The goal is zero windows where two runners disagree about which lock to use. You get there by enabling both, upgrading everything, then removing DynamoDB.
1. Confirm your current backend
A typical existing backend:
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "acme-tf-locks"
encrypt = true
}
}
2. Run both mechanisms during the transition
Add use_lockfile without removing dynamodb_table. The backend supports configuring the S3 and DynamoDB arguments at the same time specifically to support migration:
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "eu-west-1"
dynamodb_table = "acme-tf-locks"
use_lockfile = true
encrypt = true
}
}
Re-initialise so Terraform picks up the changed backend:
terraform init -reconfigure
In this dual state, a run acquires the S3 lock first and the DynamoDB lock second. Both an old runner (DynamoDB only) and a new runner (both) are safe, because they still share the DynamoDB mutex. This is the window in which you upgrade every remaining runner.
3. Set the IAM permissions for the lock object
The lock file is a normal object, so Terraform needs to write and delete it. Per the backend docs, when use_lockfile is set, s3:GetObject, s3:PutObject and s3:DeleteObject are required on the lock file, for example arn:aws:s3:::acme-tfstate-prod/platform/network/terraform.tfstate.tflock. Note that s3:DeleteObject is not required on the state file itself, because Terraform does not delete state.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "StateReadWrite",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::acme-tfstate-prod/platform/network/terraform.tfstate"
},
{
"Sid": "LockObject",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-tfstate-prod/platform/network/terraform.tfstate.tflock"
},
{
"Sid": "ListBucket",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-tfstate-prod"
}
]
}
4. Drop DynamoDB once everything is upgraded
When you are certain no runner still uses DynamoDB-only locking, remove the argument:
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "platform/network/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
encrypt = true
}
}
Run terraform init -reconfigure again, confirm a plan and apply lock cleanly, then delete the DynamoDB table and prune the DynamoDB permissions from your IAM policy.
The failure mode to avoid
The one dangerous state is a split configuration: one pipeline still on DynamoDB-only while another has moved to S3-only. They no longer share a lock and can write the same state concurrently. The OpenTofu S3 backend docs describe the same hazard when an outdated copy uses DynamoDB and an updated copy uses S3 locking, ending in concurrent access to one state file. This is exactly why step 2 keeps both mechanisms until every runner is upgraded. Do not skip straight from DynamoDB-only to S3-only across a fleet.
Standing up state backends and the IAM around them cleanly is part of the platform and DevOps foundations that make the rest of a team's work repeatable, and it is one of the first things to get right during a cloud migration or datacentre exit when you are bootstrapping new accounts.
Operating it day to day
A stuck lock. If a run is killed mid-apply, the .tflock object can be left behind. Terraform ships force-unlock to manually release a lock when unlocking failed. Use the lock ID from the error message. With native locking you can also see the orphaned .tflock object directly in the bucket, which makes diagnosis faster than reading a DynamoDB item.
Do not disable locking to get unstuck. The -lock=false flag exists but HashiCorp does not recommend it. Reaching for it under pressure is how state gets corrupted.
Watch version count on the bucket. Because locks are now objects, acquiring and releasing them writes and deletes in a versioned bucket, so the number of noncurrent versions can grow. The OpenTofu docs note that with locking enabled a lifecycle configuration to limit object versions is advisable. Add a lifecycle rule that expires noncurrent versions after a sensible window.
Keep the guardrails you already had. Separate backends per environment and read-only production state for most users remain good practice; AWS recommends isolating state per environment and limiting who can modify production. Native locking simplifies the plumbing, not the access model.
Frequently asked questions
Do I still need DynamoDB for Terraform state locking?
No. With use_lockfile = true the S3 backend locks state on its own using conditional writes, and DynamoDB-based locking is deprecated and slated for removal. Keep the table only for the short transition window while you upgrade every runner, then delete it.
What Terraform version do I need for use_lockfile?
Amazon S3 native state locking is available since Terraform 1.10.0. Every machine and CI runner that touches the state must be on 1.10.0 or newer before you remove DynamoDB, otherwise an older runner falls out of the shared lock and you risk concurrent writes.
Does S3 native locking work with S3-compatible storage like MinIO?
Only if that store implements S3 conditional writes with the If-None-Match header, which is what the lock relies on. Not all S3-compatible backends support it yet, so test locking under real concurrency before trusting it in production rather than assuming parity with AWS.
How do I recover from a stuck .tflock lock?
Use terraform force-unlock with the lock ID from the error output; it exists precisely for when unlocking failed. With native locking you can also confirm the situation by looking for the leftover .tflock object in the bucket. Avoid -lock=false as a workaround, since it removes the protection entirely.
Is DynamoDB state locking being removed?
HashiCorp documents it as deprecated and to be removed in a future minor version. It still functions today with a deprecation warning, so there is no emergency, but new backends should use use_lockfile and existing ones should plan the migration rather than wait for a forced cutover.


