Terraform S3 State Locking Without DynamoDB (2026)
May 25, 2026
Terraform's S3 backend can lock state on its own through the use_lockfile argument, with no DynamoDB table required. Generally available since Terraform 1.11, it writes a .tflock object next to your state file using S3 conditional writes. This tutorial wires it up end to end.
TL;DR
You will create an encrypted, versioned S3 bucket, point a Terraform S3 backend at it with use_lockfile = true, and confirm that a second concurrent run is blocked by a native lock object instead of a DynamoDB table. You will also migrate an existing DynamoDB-locked project with zero downtime and write a least-privilege IAM policy. Budget about 20 minutes. You need an AWS account and Terraform 1.11 or newer.
For years, locking the S3 backend meant standing up a separate DynamoDB table: another AWS service to provision, another IAM surface to manage, another line item to pay for, and another resource to keep in your Terraform code. Terraform 1.10 introduced native S3 locking as an experimental option, and Terraform 1.11 promoted it to generally available while marking the DynamoDB arguments deprecated.12 So the short answer to "does Terraform still need DynamoDB for state locking?" is no. One bucket now does both jobs.
What you'll learn
- Create a versioned, encrypted S3 bucket to hold Terraform state
- Configure the S3 backend with the
use_lockfileargument for native locking - Initialize the backend and confirm the
.tflocklock object - Reproduce a state-lock conflict and clear a stale lock with
force-unlock - Migrate an existing DynamoDB-locked project to S3 locking with no downtime
- Write a least-privilege IAM policy for the S3 backend and lock file
Prerequisites
- Terraform 1.11 or newer.
use_lockfileis generally available from 1.11; it shipped experimentally in 1.10, so 1.11+ is the version you want in production. This tutorial was verified against Terraform 1.15.2. Check yours withterraform version. - An AWS account and the AWS CLI v2, configured with credentials (
aws configure, environment variables, or AWS IAM Identity Center). Confirm withaws sts get-caller-identity. - IAM permissions to create an S3 bucket and put objects in it.
- Basic familiarity with
terraform init,plan, andapply. If remote state is new to you, the infrastructure as code fundamentals guide covers the why before this covers the how.
Step 1: Create the S3 state bucket
There is a chicken-and-egg problem with remote state: the bucket has to exist before Terraform can store state in it. The cleanest fix is to create the bucket once with the AWS CLI, then let Terraform use it from then on.
Pick a region and a globally unique bucket name. Bucket names are shared across every AWS account on earth, so suffix yours with your account ID:
export AWS_REGION="us-east-1"
export TF_STATE_BUCKET="tf-state-acme-$(aws sts get-caller-identity --query Account --output text)"
aws s3api create-bucket \
--bucket "$TF_STATE_BUCKET" \
--region "$AWS_REGION"
For any region other than us-east-1, you must also pass --create-bucket-configuration LocationConstraint="$AWS_REGION" — us-east-1 is the only region where omitting it is valid.
Next, turn on bucket versioning. The S3 backend documentation explicitly recommends it so you can recover a previous state version after an accidental deletion or a bad apply:3
aws s3api put-bucket-versioning \
--bucket "$TF_STATE_BUCKET" \
--versioning-configuration Status=Enabled
Enable default server-side encryption so both the state file and the lock file are encrypted at rest:
aws s3api put-bucket-encryption \
--bucket "$TF_STATE_BUCKET" \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
Finally, block all public access. State files routinely contain secrets, so a private bucket is non-negotiable:
aws s3api put-public-access-block \
--bucket "$TF_STATE_BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
The bucket is now ready to hold state and locks.
Step 2: Configure the S3 backend with use_lockfile
Create a working directory and a main.tf. The backend "s3" block tells Terraform where state lives; the single argument use_lockfile = true is the entire native-locking feature.3
terraform {
required_version = ">= 1.11"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
backend "s3" {
bucket = "tf-state-acme-REPLACE-WITH-YOURS"
key = "global/s3-locking-demo/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
resource "random_pet" "server" {
length = 2
}
output "server_name" {
value = random_pet.server.id
}
Replace the bucket value with the name you created in Step 1. A few notes on the backend block:
keyis the object path inside the bucket. The lock file is derived from it: Terraform writesglobal/s3-locking-demo/terraform.tfstate.tflock— the same path with a.tflocksuffix.3encrypt = trueasks Terraform to encrypt the state and lock objects it uploads, on top of the bucket-level default from Step 1.use_lockfile = trueenables native locking. There is nodynamodb_tableargument anywhere in this configuration.- Backend blocks cannot use variables or interpolation, so the values are literal. For environment-specific values, use partial configuration and pass
-backend-configat init time.
The random_pet resource is a deliberately boring demo: it generates a friendly name and stores it in state without provisioning or billing any real infrastructure.4 It exists so you have state to lock.
Step 3: Initialize the backend and confirm the lock object
Initialize the working directory. Terraform configures the S3 backend and downloads the random provider:
terraform init
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Installing hashicorp/random v3.9.0...
Terraform has been successfully initialized!
Now apply. Type yes at the prompt:
terraform apply
Terraform will perform the following actions:
# random_pet.server will be created
+ resource "random_pet" "server" {
+ id = (known after apply)
+ length = 2
+ separator = "-"
}
Plan: 1 to add, 0 to change, 0 to destroy.
random_pet.server: Creating...
random_pet.server: Creation complete after 0s [id=stirring-mongoose]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Outputs:
server_name = "stirring-mongoose"
Look at what landed in the bucket:
aws s3 ls "s3://$TF_STATE_BUCKET/global/s3-locking-demo/"
2026-05-25 09:14:02 1180 terraform.tfstate
You will see terraform.tfstate but no .tflock file. That is expected and correct: the lock object only exists during an operation. Terraform creates it at the start of a plan or apply and deletes it the moment the operation finishes. Between runs, there is no lock to see. Under the hood, Terraform creates that object with an S3 conditional write — a PutObject carrying an If-None-Match header, a capability S3 made generally available in August 2024 — so the write succeeds only if no lock object already exists.5
Step 4: Watch the lock conflict in action
To see locking actually block a concurrent run, you need two terminals in the same project directory.
Terminal 1 — start an apply that has real work to do, then stop at the approval prompt. The -replace flag forces Terraform to plan a replacement of the demo resource, so the apply always has something to confirm. Terraform acquires the state lock at the start of the operation and holds it until the operation completes, including while it waits for you to answer the prompt:
terraform apply -replace="random_pet.server"
# random_pet.server will be replaced, as requested
-/+ resource "random_pet" "server" {
~ id = "stirring-mongoose" -> (known after apply)
length = 2
}
Plan: 1 to add, 0 to change, 1 to destroy.
Do you want to perform these actions?
Enter a value:
Leave that prompt sitting there. The lock is held.
Terminal 2 — while Terminal 1 waits, try any state operation:
terraform plan
╷
│ Error: Error acquiring the state lock
│
│ Error message: operation error S3: PutObject, https response error
│ StatusCode: 412, api error PreconditionFailed: At least one of the
│ pre-conditions you specified did not hold
│ Lock Info:
│ ID: 4d1f0e87-3b6a-2c9d-a5e1-77c0b9f4e220
│ Path: tf-state-acme-.../global/s3-locking-demo/terraform.tfstate.tflock
│ Operation: OperationTypeApply
│ Who: you@workstation
│ Version: 1.15.2
│ Created: 2026-05-25 09:21:44 ...
│
│ Terraform acquires a state lock to protect the state from being written
│ by multiple users at the same time. Please resolve the issue above and
│ try again.
╵
The StatusCode: 412 and PreconditionFailed are the heart of it. Terminal 2's conditional PutObject failed because the .tflock object already existed — exactly the protection you want. Go back to Terminal 1, answer yes (or no), and the lock is released; rerun Terminal 2 and it succeeds.
If a Terraform process crashes mid-run, the .tflock object is left behind, because S3 objects do not expire on their own. That is a stale lock. Verify nobody else is genuinely running Terraform, then release it with the ID from the error message:
terraform force-unlock 4d1f0e87-3b6a-2c9d-a5e1-77c0b9f4e220
In CI pipelines, where brief contention between back-to-back jobs is normal, prefer terraform plan -lock-timeout=120s. Terraform then retries lock acquisition for two minutes before giving up, instead of failing on the first conflict.
Step 5: Migrate an existing project off DynamoDB
If you already run the S3 backend with a dynamodb_table, you can move to native locking with zero downtime, because the S3 and DynamoDB arguments are allowed to coexist. When both are set, Terraform acquires a lock from both systems on every run.3 That overlap is what makes the migration safe.
Suppose your current backend looks like this:
backend "s3" {
bucket = "tf-state-acme-REPLACE-WITH-YOURS"
key = "global/s3-locking-demo/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
Phase 1 — run both. Add use_lockfile = true while keeping dynamodb_table:
backend "s3" {
bucket = "tf-state-acme-REPLACE-WITH-YOURS"
key = "global/s3-locking-demo/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
use_lockfile = true
}
Re-initialize so Terraform picks up the changed backend configuration:
terraform init -reconfigure
Run a normal plan/apply cycle. Both the DynamoDB item and the S3 .tflock object are now created and deleted on every run. Let this phase ride for a few days across your whole team and CI.
Phase 2 — drop DynamoDB. Once you trust native locking, remove the DynamoDB arguments:
backend "s3" {
bucket = "tf-state-acme-REPLACE-WITH-YOURS"
key = "global/s3-locking-demo/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
terraform init -reconfigure
Phase 3 — clean up. With every project migrated, delete the now-unused table and the IAM permissions that referenced it:
aws dynamodb delete-table --table-name terraform-locks --region us-east-1
That is one fewer AWS service in your account, one fewer thing to pay for, and one fewer resource to keep in your Terraform code.
Step 6: Lock down the backend with a least-privilege IAM policy
Your own admin credentials already have enough access to run everything above. For a CI runner or a dedicated automation role, scope permissions tightly. The S3 backend documentation defines exactly what is required, and the lock file deserves attention because its permission set differs from the state file's:3
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListStatePrefix",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::tf-state-acme-REPLACE-WITH-YOURS",
"Condition": {
"StringEquals": {
"s3:prefix": "global/s3-locking-demo/terraform.tfstate"
}
}
},
{
"Sid": "ReadWriteState",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::tf-state-acme-REPLACE-WITH-YOURS/global/s3-locking-demo/terraform.tfstate"
},
{
"Sid": "ManageLockFile",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::tf-state-acme-REPLACE-WITH-YOURS/global/s3-locking-demo/terraform.tfstate.tflock"
}
]
}
The detail that trips people up: the .tflock resource needs s3:DeleteObject because Terraform deletes the lock object when an operation ends, but the state file does not — Terraform never deletes state, so granting s3:DeleteObject on terraform.tfstate would be excess privilege.3 If you point kms_key_id at a customer-managed KMS key, also grant the role kms:Encrypt, kms:Decrypt, and kms:GenerateDataKey on that key.
Verification
Confirm three things before you call this done.
State is encrypted at rest:
aws s3api head-object \
--bucket "$TF_STATE_BUCKET" \
--key "global/s3-locking-demo/terraform.tfstate" \
--query 'ServerSideEncryption' --output text
AES256
Locking actually blocks concurrency: rerun the two-terminal test from Step 4 and confirm Terminal 2 fails with Error acquiring the state lock.
No stale lock lingers: with no Terraform operation running, list the state prefix and confirm you see terraform.tfstate but no terraform.tfstate.tflock.
aws s3 ls "s3://$TF_STATE_BUCKET/global/s3-locking-demo/"
A .tflock object present while nothing runs is a stale lock — clear it with terraform force-unlock and the lock ID from the error, as shown in Step 4.
Troubleshooting
Error acquiring the state lock on a normal, solo run. This is a stale lock left by a crashed process. Confirm no teammate or CI job is mid-run, then terraform force-unlock <ID> using the ID from the error. As a last resort, delete the .tflock object from the bucket directly.
terraform init reports the backend configuration changed. Expected any time you edit the backend "s3" block, including when you add use_lockfile. Run terraform init -reconfigure. Use terraform init -migrate-state instead only when the bucket or key itself changes.
AccessDenied writing the lock file. The IAM policy is missing s3:PutObject or s3:DeleteObject on the *.tflock resource. Add the ManageLockFile statement from Step 6 — a policy that only covers terraform.tfstate will read and write state fine but fail the instant locking kicks in.
IllegalLocationConstraintException from create-bucket. You are creating the bucket in a region other than us-east-1 without --create-bucket-configuration LocationConstraint="$AWS_REGION". Add that flag for every region except us-east-1.
Old tutorials still tell you to create a DynamoDB table. They predate Terraform 1.10. For 1.11 and newer, use_lockfile is the supported path and the dynamodb_table argument is deprecated and slated for removal in a future minor version.23
Next steps
You now have a single-bucket S3 backend that stores and locks state with no DynamoDB dependency. From here:
- Split state per environment with
workspace_key_prefixand a Terraform workspace per stage. - Move the backend bootstrap into automation so the bucket itself is reproducible.
- Wire
terraform plan/applyinto CI with-lock-timeoutso back-to-back jobs queue gracefully. The patterns in deploying a production app to Fly.io and migrating Kubernetes ingress to the Gateway API pair well with a locked remote state.
Native S3 locking is one of those rare changes that removes a moving part instead of adding one. Take the win.
Footnotes
-
Terraform v1.10.0 release (2024-11-27) — introduced experimental S3 native state locking. https://github.com/hashicorp/terraform/releases/tag/v1.10.0 ↩
-
Terraform v1.11.0 release (2025-02-27) —
use_lockfilegenerally available; DynamoDB-based locking deprecated. https://github.com/hashicorp/terraform/releases/tag/v1.11.0 ↩ ↩2 -
"Backend Type: s3" — HashiCorp Developer documentation (state locking,
use_lockfile,.tflock, IAM permissions, migration). https://developer.hashicorp.com/terraform/language/backend/s3 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 -
hashicorp/random provider — Terraform Registry. https://registry.terraform.io/providers/hashicorp/random/latest ↩
-
"Amazon S3 now supports conditional writes" — AWS What's New, 2024-08-20. https://aws.amazon.com/about-aws/whats-new/2024/08/amazon-s3-conditional-writes/ ↩