If you've been running Terraform on AWS for any length of time, you know the setup: an S3 bucket for state storage, a DynamoDB table for state locking, and a handful of IAM policies tying them together. It works. It has worked for years.
But it has always carried a cost that rarely gets discussed openly. That cost isn't just money, though a DynamoDB table with on-demand billing adds up across multiple teams and environments.
The real cost is complexity. Every new AWS environment needs both resources provisioned before Terraform can manage anything else. Every engineer who sets up their first Terraform backend has to understand why two completely different AWS services are responsible for what is logically one thing: storing and protecting state. And every incident involving a stuck lock has required someone to manually delete a record from DynamoDB to unblock the team.
In November 2024, AWS announced that S3 now supports native object locking for Terraform state files, meaning DynamoDB is no longer required for state locking. Terraform 1.10 added support for this feature, and it's now generally available.
In this tutorial, you'll learn:
What S3 native locking is and how it works
How to set it up from scratch if you're starting a new project
How to migrate an existing S3 + DynamoDB setup to S3 native locking safely
How to verify locking is working and handle edge cases
By the end, you'll have a simpler, cleaner Terraform backend with one fewer AWS resource to manage.
Table of Contents
How S3 Native Locking Compares to the S3 + DynamoDB Approach
Part 1: Fresh Setup – How to Configure S3 Native Locking from Scratch
Part 2: Migration – How to Move from S3 + DynamoDB to S3 Native Locking
What is Terraform State Locking?
Before looking at the new approach, it helps to understand what state locking is solving.
Terraform stores everything it knows about your infrastructure in a state file – a JSON document that maps your configuration to real AWS resources. When you run terraform apply, Terraform reads this file, calculates the difference between the current state and your configuration, and makes the necessary changes.
The problem arises when two engineers or two CI/CD pipelines run and try to apply changes at the same time. If both read the state file simultaneously, calculate changes independently, and both try to write back, you get a race condition. The second write overwrites changes from the first, and your state is now out of sync with reality. This is a serious problem that can cause resources to be untracked, doubled, or destroyed unexpectedly.
State locking solves this by creating a lock when any operation starts that could modify state. If a lock already exists, Terraform refuses to proceed and reports who holds the lock and when it was acquired. Only one operation can hold the lock at a time. When the operation completes, the lock is released.
Terraform Run A State File / Lock Terraform Run B
(User 1) (S3/DynamoDB) (User 2)
| | |
|------- 1. Acquire Lock ---------->| |
| | |
|<------ 2. Lock Granted -----------| |
| | |
| |------- 3. Acquire Lock --->|
| [PROCESSING] | |
| (Modifying Infrastructure) |<------ 4. Lock Denied -----|
| | (Wait / Retry) |
| | |
|------- 5. Release Lock ---------->| |
| | |
| [COMPLETED] |<------ 6. Lock Granted ----|
| | |
| | [PROCESSING] |
| | (Modifying Infrastructure) |
| | |
What Is S3 Native State Locking?
Previously, Terraform's S3 backend used a DynamoDB table as the locking mechanism. When a lock was needed, Terraform wrote a record to DynamoDB with a LockID primary key. DynamoDB's conditional writes guaranteed that only one process could create that record, which is what made the locking atomic.
S3 native locking uses S3 Object Lock instead. S3 Object Lock is an S3 feature originally designed to enforce WORM (Write Once, Read Many) compliance for regulatory requirements. AWS extended this capability to support Terraform's state locking workflow.
When S3 native locking is enabled in your Terraform backend:
Terraform writes your state to an
.tfstateobject in S3 (as before)To acquire a lock, Terraform uses S3's conditional write operations – specifically the
if-none-matchconditional header to create a lock file atomicallyIf the lock file already exists, S3 rejects the write, and Terraform reports that a lock is held
When the operation completes, Terraform deletes the lock file to release the lock.
The key difference from DynamoDB: the entire locking mechanism lives inside S3. No second service. No second set of IAM permissions. No second resource to provision.
Note: This feature requires Terraform version 1.10.0 or later and an S3 bucket with Object Lock enabled. Object Lock must be enabled at bucket creation time. You can't enable it on an existing bucket through the console or CLI. But there is a supported workaround for existing buckets, which we'll cover in Part 2.
How S3 Native Locking Compares to the S3 + DynamoDB Approach
| Aspect | S3 + DynamoDB (Old) | S3 Native Locking (New) |
|---|---|---|
| AWS services required | S3 + DynamoDB | S3 only |
| IAM permissions needed | S3 + DynamoDB permissions | S3 permissions only |
| Terraform version | Any | 1.10.0 or later |
| Setup complexity | Two resources, two IAM scopes | One resource |
| Stuck lock resolution | Delete DynamoDB record | Delete S3 lock file |
| Cost | S3 storage + DynamoDB on-demand | S3 storage only |
| Object Lock requirement | Not required | Required on S3 bucket |
| Locking mechanism | DynamoDB conditional writes | S3 conditional writes (if-none-match) |
| State versioning | S3 Versioning (recommended) | S3 Versioning (required for full safety) |
The functional behavior from Terraform's perspective is identical. Locking works the same way. The lock information displayed when a lock is held has the same structure. The only difference is what happens under the hood.
Prerequisites
Before you start, make sure you have the following in place:
- Terraform 1.10.0 or later installed. Check your version:
terraform version
If you need to upgrade, follow the official upgrade guide.
- AWS CLI installed and configured with credentials that have permission to create and manage S3 buckets.
aws --version
aws sts get-caller-identity # confirm you're authenticated
IAM permissions to perform the following S3 actions:
s3:CreateBuckets3:PutBucketVersionings3:PutBucketEncryptions3:PutObjectLegalHolds3:PutObjectRetentions3:GetObjects3:PutObjects3:DeleteObjects3:ListBucket
For the migration path: access to your existing Terraform project and the S3 bucket and DynamoDB table currently in use.
Part 1: Fresh Setup – How to Configure S3 Native Locking from Scratch
Follow this section if you're starting a new Terraform project and want to use S3 native locking from the beginning.
Step 1: Create the S3 Bucket with Versioning and Encryption
Object Lock must be enabled at bucket creation time. You can't add it afterward through the standard console flow. Create the bucket using the AWS CLI with Object Lock enabled:
aws s3api create-bucket \
--bucket your-project-terraform-state \
--region us-east-1 \
--object-lock-enabled-for-bucket
Note: For regions other than us-east-1, add the --create-bucket-configuration flag.
aws s3api create-bucket \
--bucket your-project-terraform-state \
--region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1 \
--object-lock-enabled-for-bucket
Now enable versioning on the bucket. Versioning is required alongside Object Lock and allows Terraform to recover previous state versions if something goes wrong:
aws s3api put-bucket-versioning \
--bucket your-project-terraform-state \
--versioning-configuration Status=Enabled
Enable server-side encryption so your state files are encrypted at rest:
aws s3api put-bucket-encryption \
--bucket your-project-terraform-state \
--server-side-encryption-configuration '{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
},
"BucketKeyEnabled": true
}
]
}'
Block all public access to the bucket. A Terraform state file contains resource IDs, IP addresses, and potentially sensitive values. It should never be publicly accessible:
aws s3api put-public-access-block \
--bucket your-project-terraform-state \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
Verify the bucket configuration:
# Confirm Object Lock is enabled
aws s3api get-object-lock-configuration \
--bucket your-project-terraform-state
# Confirm versioning is enabled
aws s3api get-bucket-versioning \
--bucket your-project-terraform-state
# Confirm encryption is configured
aws s3api get-bucket-encryption \
--bucket your-project-terraform-state
Expected output for the Object Lock check:
{
"ObjectLockConfiguration": {
"ObjectLockEnabled": "Enabled"
}
}
Step 2: Configure the Terraform Backend with Native Locking
In your Terraform project, create or update your backend.tf file:
terraform {
backend "s3" {
bucket = "your-project-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
# Enable S3 native state locking
# Requires Terraform 1.10.0+ and a bucket with Object Lock enabled
use_lockfile = true
# Encryption at rest
encrypt = true
}
}
The critical difference from the old configuration is the use_lockfile = true parameter. Notice what is absent: there's no dynamodb_table argument. No DynamoDB table. No second service.
Here's a direct comparison of the old and new configurations:
Old configuration (S3 + DynamoDB):
terraform {
backend "s3" {
bucket = "your-project-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock" # this goes away
}
}
New configuration (S3 native locking):
terraform {
backend "s3" {
bucket = "your-project-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true # this replaces dynamodb_table
}
}
Step 3: Initialize and Verify
Run terraform init to initialize the backend:
terraform init
Expected output:
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
Terraform has been successfully initialized!
Run a plan to confirm everything is working end-to-end:
terraform plan
If locking is working, you'll see a brief pause while Terraform acquires the lock before the plan output appears. You'll also see the lock information if you look at the S3 bucket – a .tflock file will appear temporarily alongside your state file during the operation and disappear when it completes.
Part 2: Migration – How to Move from S3 + DynamoDB to S3 Native Locking
Follow this section if you have an existing Terraform setup using an S3 bucket and DynamoDB table for state locking, and you want to migrate to S3 native locking.
Important: Migration requires a maintenance window or at minimum a period where no Terraform operations are running. You're changing the backend configuration, which means all team members and CI/CD pipelines must stop running terraform plan or terraform apply during the migration. The migration itself takes under 10 minutes.
Step 1: Verify Your Current Setup
Before making any changes, document your existing backend configuration and confirm the state file is accessible:
# Confirm your state file is in S3
aws s3 ls s3://your-existing-bucket/path/to/terraform.tfstate
# Confirm the DynamoDB table exists
aws dynamodb describe-table \
--table-name your-dynamodb-lock-table \
--query 'Table.TableStatus'
Check your current backend.tf and note the exact values:
# Your current backend.tf - note these values before changing anything
terraform {
backend "s3" {
bucket = "your-existing-bucket" # note this
key = "path/to/terraform.tfstate" # note this
region = "us-east-1" # note this
encrypt = true
dynamodb_table = "your-dynamodb-lock-table" # this will be removed
}
}
Run one final plan to confirm the current state is clean and there are no unexpected changes pending:
terraform plan
If the plan shows no changes, you're in a safe state to proceed.
Step 2: Enable Object Lock on the Existing S3 Bucket
This is the most important step in the migration. Object Lock can't normally be enabled on an existing bucket. It's a setting that must be configured at creation time.
But AWS provides a way to enable Object Lock on an existing bucket through a support request or through a direct API call that's not exposed in the standard console UI. AWS has officially documented this path for the Terraform migration use case.
Run the following AWS CLI command to enable Object Lock on your existing bucket:
aws s3api put-object-lock-configuration \
--bucket your-existing-bucket \
--object-lock-configuration '{"ObjectLockEnabled": "Enabled"}'
Note: This command enables Object Lock in governance mode with no default retention, meaning it enables the locking capability without setting a default retention period on all objects. This is exactly what Terraform's native locking needs: the ability to create and delete lock files, not permanent object retention.
Verify Object Lock is now enabled:
aws s3api get-object-lock-configuration \
--bucket your-existing-bucket
Expected output:
{
"ObjectLockConfiguration": {
"ObjectLockEnabled": "Enabled"
}
}
Also verify that versioning is already enabled (it should be if you are running a production Terraform setup):
aws s3api get-bucket-versioning \
--bucket your-existing-bucket
Expected output:
{
"Status": "Enabled"
}
If versioning isn't enabled, enable it before proceeding:
aws s3api put-bucket-versioning \
--bucket your-existing-bucket \
--versioning-configuration Status=Enabled
Step 3: Update the Terraform Backend Configuration
Update your backend.tf to remove the dynamodb_table argument and add use_lockfile = true:
terraform {
backend "s3" {
bucket = "your-existing-bucket"
key = "path/to/terraform.tfstate"
region = "us-east-1"
encrypt = true
# Add this:
use_lockfile = true
# Remove this line entirely:
# dynamodb_table = "your-dynamodb-lock-table"
}
}
Your updated backend.tf should look like this:
terraform {
backend "s3" {
bucket = "your-existing-bucket"
key = "path/to/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
Step 4: Reinitialize Terraform
Run terraform init with the -reconfigure flag. This flag tells Terraform that the backend configuration has changed intentionally and to reinitialize without prompting you to copy state (the state is already in the same bucket):
terraform init -reconfigure
Expected output:
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
Terraform has been successfully initialized!
If you see an error here: The most common cause is that Object Lock wasn't successfully enabled on the bucket. Re-run the verification from Step 2 before proceeding.
Step 5: Verify the Migration
Run a plan to confirm Terraform is working correctly with the new backend configuration:
terraform plan
The plan should:
Complete successfully
Show the same result as the plan you ran in Step 1 (no changes, or the same changes as before)
NOT mention DynamoDB anywhere in its output
To confirm that locking is actually using S3 instead of DynamoDB, open a second terminal and run a plan while the first one is running. You should see the second terminal output a lock error that mentions S3, not DynamoDB:
╷
│ Error: Error acquiring the state lock
│
│Error message: operation error S3: PutObject, https response error StatusCode: 409,
│ RequestID: ..., api error Conflict: Object lock already exists for this key.
│
│ Lock Info:
│ ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
│ Path: your-existing-bucket/path/to/terraform.tfstate.tflock
│ Operation: OperationTypePlan
│ Who: user@hostname
│ Version: 1.10.0
│ Created: 2026-05-06 14:22:01 UTC
│ Info:
╵
The Path field shows .tfstate.tflock, a file in your S3 bucket, not a DynamoDB record. This confirms that locking is now handled entirely by S3.
Step 6: Clean Up the DynamoDB Table
Once you've confirmed the migration is working correctly and your team has run at least one successful plan and apply cycle using the new backend, you can remove the DynamoDB table.
Wait at least 24-48 hours before deleting the DynamoDB table if you have CI/CD pipelines or multiple team members. This gives time to catch any pipeline that wasn't updated with the new backend configuration.
When you're ready, delete the DynamoDB table:
aws dynamodb delete-table \
--table-name your-dynamodb-lock-table
Confirm the deletion:
aws dynamodb describe-table \
--table-name your-dynamodb-lock-table
Expected output:
An error occurred (ResourceNotFoundException) when calling the DescribeTable operation:
Requested resource not found
This error confirms that the table is gone. The migration is complete.
If you provisioned the DynamoDB table using Terraform (which is the recommended pattern), remove the resource from your Terraform configuration and run terraform apply to destroy it via Terraform rather than the CLI directly. This keeps your state clean:
# Remove this entire block from your Terraform configuration:
resource "aws_dynamodb_table" "terraform_state_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
After removing the block, run:
terraform apply
Terraform will detect that the DynamoDB table resource has been removed from configuration and will destroy the table.
How to Verify That Locking Is Working
After completing either the fresh setup or the migration, use this procedure to independently verify that locking is functioning correctly.
Method 1: Observe the lock file during an operation
In one terminal, start a long-running plan against a configuration with many resources:
terraform plan
While it's running, in a second terminal, check for the lock file in S3:
aws s3 ls s3://your-bucket/path/to/ | grep tflock
You should see a file like:
2026-05-06 14:22:01 512 terraform.tfstate.tflock
After the plan completes, run the same command again. The .tflock file should be gone.
Method 2: Read the lock file contents
While a plan is running, download and read the lock file to see its contents:
aws s3 cp \
s3://your-bucket/path/to/terraform.tfstate.tflock \
/tmp/current.lock && cat /tmp/current.lock
Expected output (formatted for readability):
{
"ID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"Operation": "OperationTypePlan",
"Info": "",
"Who": "tolani@dev-machine",
"Version": "1.10.0",
"Created": "2026-05-06T14:22:01.123456789Z",
"Path": "your-bucket/path/to/terraform.tfstate"
}
This is the same lock information that Terraform displays when a lock is held. It's now a JSON file in S3 rather than a record in DynamoDB.
How to Handle a Stuck Lock
With the DynamoDB backend, resolving a stuck lock meant deleting a record from the DynamoDB table. With S3 native locking, it means deleting the .tflock file from S3.
A lock can get stuck if:
A
terraform applyorplanprocess was killed mid-executionA CI/CD pipeline runner crashed during a Terraform operation
A network interruption prevented the lock release from completing
Here's how you can check for a stuck lock:
aws s3 ls s3://your-bucket/path/to/ | grep tflock
If a .tflock file exists and no Terraform operation is currently running, it is a stuck lock.
You can also read the lock to understand who held it:
aws s3 cp \
s3://your-bucket/path/to/terraform.tfstate.tflock \
/tmp/stuck.lock && cat /tmp/stuck.lock
This tells you who (Who field) was running the operation, what operation it was (Operation field), and when it was acquired (Created field).
And you can force-unlock using Terraform like this:
terraform force-unlock LOCK-ID
Replace LOCK-ID with the ID value from the lock file contents. For example:
terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890
Terraform will confirm:
Do you really want to force-unlock?
Terraform will remove the lock on the remote state.
This will allow local Terraform commands to modify this state, even though it
may be still be in use. Only 'yes' will be accepted to confirm.
Enter a value: yes
Terraform state has been successfully unlocked!
An alternative is to delete the lock file directly via CLI. If terraform force-unlock doesn't work (for example, because you are running in a CI environment without Terraform available), delete the lock file directly:
aws s3 rm s3://your-bucket/path/to/terraform.tfstate.tflock
Only delete the lock file if you are certain no Terraform operation is currently running. Deleting a lock that is actively held by a running operation will allow a second concurrent operation to start, which is exactly the race condition locking is designed to prevent.
Rollback Plan: If Something Goes Wrong
If you encounter problems after migrating, you can roll back to the S3 + DynamoDB setup with these steps.
Step 1: Stop all Terraform operations in your team and CI/CD pipelines.
Step 2: Recreate the DynamoDB table if you already deleted it:
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
Step 3: Revert backend.tf to the previous configuration:
terraform {
backend "s3" {
bucket = "your-existing-bucket"
key = "path/to/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock" # restored
# Remove: use_lockfile = true
}
}
Step 4: Reinitialize:
terraform init -reconfigure
Step 5: Verify:
terraform plan
The state file hasn't moved, so there's no data loss during a rollback. The only change is which locking mechanism Terraform uses.
Note: Object Lock being enabled on the S3 bucket doesn't prevent the rollback. Object Lock and DynamoDB locking can coexist, Object Lock simply adds a capability to the bucket. Using dynamodb_table in your backend config tells Terraform to use DynamoDB regardless of whether Object Lock is enabled on the bucket.
Security Best Practices for Your State Bucket
Migrating to S3 native locking is a good opportunity to review the overall security configuration of your state bucket. Here are the practices every production Terraform state bucket should implement:
Enable Versioning (Required)
Versioning is a hard requirement for S3 native locking to work safely. It ensures that if a state file is accidentally overwritten or corrupted, you can restore a previous version.
aws s3api put-bucket-versioning \
--bucket your-state-bucket \
--versioning-configuration Status=Enabled
Block All Public Access (Non-Negotiable)
Your state file contains resource ARNs, IP addresses, and may contain sensitive values passed through Terraform variables. It must never be publicly accessible.
aws s3api put-public-access-block \
--bucket your-state-bucket \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
Enable Server-Side Encryption
Always encrypt state files at rest. AES256 is the minimum. If your organization requires KMS key management:
aws s3api put-bucket-encryption \
--bucket your-state-bucket \
--server-side-encryption-configuration '{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "aws:kms",
"KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789012:key/your-kms-key-id"
},
"BucketKeyEnabled": true
}
]
}'
Apply Least-Privilege IAM Permissions
The role or user that Terraform uses to access the state bucket should have only the permissions it needs. Here's a minimal IAM policy for S3 native locking:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TerraformStateAccess",
"Effect": "Allow",
"Action": [
"s3:ListBucket",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::your-state-bucket",
"arn:aws:s3:::your-state-bucket/*"
]
},
{
"Sid": "TerraformStateLocking",
"Effect": "Allow",
"Action": [
"s3:GetObjectLegalHold",
"s3:PutObjectLegalHold",
"s3:GetObjectRetention",
"s3:PutObjectRetention"
],
"Resource": "arn:aws:s3:::your-state-bucket/*.tflock"
}
]
}
Notice what is absent: there are no DynamoDB permissions. This is a cleaner, smaller permission set than the old approach required.
Enable Access Logging
Log all access to your state bucket in CloudTrail or S3 server access logs. This gives you an audit trail of every time state was read, written, or locked:
aws s3api put-bucket-logging \
--bucket your-state-bucket \
--bucket-logging-status '{
"LoggingEnabled": {
"TargetBucket": "your-logging-bucket",
"TargetPrefix": "terraform-state-access/"
}
}'
Conclusion
AWS S3 native state locking removes the need for a DynamoDB table from your Terraform backend setup. The result is simpler infrastructure, a smaller IAM permission surface, and one fewer service to provision, monitor, and pay for across every environment your team manages.
Here's a summary of what you accomplished:
Understood what state locking is and why it's required for safe Terraform operations
Compared S3 native locking to the existing S3 + DynamoDB approach
Set up a fresh Terraform backend using S3 native locking with correct bucket configuration
Migrated an existing backend from S3 + DynamoDB to S3 native locking safely
Learned how to verify locking, handle stuck locks, and roll back if needed
Applied security best practices to the state bucket
This pattern – using S3 native locking – is the recommended approach for all new Terraform projects on AWS going forward. If you're managing a large estate with multiple Terraform backends, consider automating the migration using a script or Terraform module that applies the pattern across all your state buckets.
If you are building or optimizing cloud infrastructure for a startup and want a complete reference for production-ready Terraform modules, CI/CD pipeline patterns, and infrastructure runbooks, check out The Startup DevOps Field Guide. It covers the full lifecycle of AWS infrastructure from initial setup to production reliability.