Terraform State Locking Without DynamoDB: S3 Native Locking Explained

작성자

카테고리:

← 피드로
DEV Community · Tanseer · 2026-07-21 개발(SW)

A beginner friendly guide to S3 native state locking and why it makes your Terraform setup so much cleaner

Setting up remote state in Terraform used to come with a small annoyance. You could not just use an S3 bucket on its own. You also had to create a DynamoDB table sitting right next to it, purely to handle locking. Two AWS services for something that felt like one job.

That has changed. Starting with Terraform 1.10, S3 can handle state locking by itself. No DynamoDB table, no extra resource to provision, no extra permissions to hand out. In this post I will walk through what state locking is, why it needed DynamoDB in the first place, what changed under the hood, and the exact steps to switch to the simpler setup.

This is written for people who are still new to AWS and Terraform, so I will explain each term the first time it shows up.

A quick refresher on the Terraform state file

When you run Terraform, it needs to remember what it built. It keeps this record in a file called the state file, usually named terraform.tfstate. Think of it as a map that connects the resources written in your code to the real resources living in your AWS account.

If you work alone on your laptop, that file can just sit on your machine. But the moment a second person joins, everyone needs to share the same map. If two people have two different copies, they will fight over what exists and what does not.

The common fix is to store the state file in a shared location that the whole team points to. This shared location is called a backend. An S3 bucket is one of the most popular backends because it is cheap, durable, and simple. A bucket is just a storage container in S3 where you keep files, which AWS calls objects.

Why state locking matters

Here is the problem that locking solves.

Say two engineers on your team both run terraform apply at almost the same moment. Both runs read the current state file. Both make their changes. Both try to write the updated state back. One write lands on top of the other, and now your state file is scrambled. In the worst case Terraform gets confused about what already exists and starts creating duplicates or destroying things it should not touch.

State locking stops this. Before Terraform makes any change, it grabs a lock. While that lock is held, any other run that tries to start is told to wait. Once the first run finishes, it releases the lock and the next person can go. Only one operation writes at a time, so the state file stays clean.

The question is where that lock actually lives.

The old way: a DynamoDB table just for locks

For a long time, the S3 backend could store your state, but it could not lock it. S3 on its own had no simple way to say “only write this if nobody else is holding it.” So Terraform borrowed a second service to keep track of locks: DynamoDB, which is a fast key value database from AWS.

The setup looked like this. You created a DynamoDB table with a single partition key called LockID. A partition key, also called a hash key, is just the main field DynamoDB uses to find a row. When Terraform started an operation, it wrote a lock item into that table. When it finished, it deleted the item. If a second run showed up and saw a lock item already there, it knew the state was busy.

The Terraform code for that table usually looked like this.

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

Enter fullscreen mode Exit fullscreen mode

And the backend block that tied it all together looked like this.

terraform {
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "global/s3/terraform.tfstate"
    region         = "ap-south-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

Enter fullscreen mode Exit fullscreen mode

Here key is the path to the state file inside the bucket, and encrypt = true tells S3 to encrypt the file at rest.

None of this is hard on its own. But notice how much you are carrying. You provision a whole extra service. You manage its name and region and make sure they match the backend config. You grant IAM permissions for it, where IAM is the AWS system that decides who is allowed to do what. And you have one more thing that can silently break, like a table created in the wrong region or a name typo that only shows up when a lock fails.

What actually changed

Two things had to line up for S3 to do locking on its own.

First, in August 2024, AWS added conditional writes to S3. This is the important piece. A conditional write lets you upload an object only if it does not already exist. Technically this works through an HTTP header called If-None-Match. In plain terms, you can now tell S3 “create this file, but only if it is not already there, otherwise refuse.” That refuse behavior is exactly what a lock needs. If the lock file is already there, your write bounces, which means someone else has the lock.

Second, Terraform 1.10 (released in November 2024) added a new backend option called use_lockfile. When you turn it on, Terraform uses that S3 conditional write trick to manage locks directly in the same bucket as your state. DynamoDB is no longer part of the picture.

Terraform 1.11 went a step further and marked the old dynamodb_table argument as deprecated, which is the maintainers’ way of saying the DynamoDB path is on its way out and you should move over.

One quick clarification so you do not get tripped up. S3 native state locking has nothing to do with a feature called S3 Object Lock. Object Lock is a compliance feature that stops files from being deleted or changed for a set period. That is not what we are using here. Terraform state locking uses conditional writes, and it works on a plain, normal S3 bucket with no special mode turned on.

How it works under the hood

When you enable use_lockfile and run an operation, Terraform creates a small lock file in the same bucket, right next to your state file. It reuses your state path and adds a .tflock extension.

So if your state file is at:

global/s3/terraform.tfstate

Enter fullscreen mode Exit fullscreen mode

the lock file will appear at:

global/s3/terraform.tfstate.tflock

Enter fullscreen mode Exit fullscreen mode

While an operation runs, that .tflock object exists. When the operation ends, Terraform deletes it. If someone else runs Terraform during that window, S3 rejects their conditional write because the lock file is already there, and Terraform shows them an error instead of letting two runs collide.

The error looks something like this.

Error: Error acquiring the state lock

Error message: operation error S3: PutObject, https response error
StatusCode: 412, RequestID: ..., HostID: ..., api error PreconditionFailed:
At least one of the pre-conditions you specified did not hold

Lock Info:
  ID:        3f6a1c9e-...-b2d4
  Path:      my-terraform-state-bucket/global/s3/terraform.tfstate.tflock
  Operation: OperationTypeApply
  Who:       sahil@laptop
  Created:   2026-07-21 09:14:22.5 +0000 UTC

Enter fullscreen mode Exit fullscreen mode

Notice the StatusCode: 412 and PreconditionFailed. That 412 is the HTTP status S3 returns when a conditional write is refused because the object already exists. In other words, the lock is doing its job. Everything you need to identify who holds the lock is right there in the output.

Steps to set it up

Here is the full walkthrough for a fresh setup.

1. Check your Terraform version

use_lockfile needs Terraform 1.10.0 or newer. Check what you have.

terraform version

Enter fullscreen mode Exit fullscreen mode

If you see anything older than 1.10, upgrade before continuing.

2. Create the S3 bucket for your state

If you already have a state bucket you can skip ahead. If not, create one with the AWS CLI, which is the command line tool for talking to AWS. Bucket names have to be globally unique across all of AWS, so pick something specific to you.

aws s3api create-bucket \
  --bucket my-terraform-state-bucket \
  --region ap-south-1 \
  --create-bucket-configuration LocationConstraint=ap-south-1

Enter fullscreen mode Exit fullscreen mode

A small gotcha. Every region except us-east-1 needs that --create-bucket-configuration LocationConstraint line. If you are using us-east-1, drop that last line, because that region rejects it.

3. Turn on versioning

Versioning tells S3 to keep old copies of a file each time it changes. For a state file this is a safety net. If something ever corrupts your state, you can roll back to an earlier version.

aws s3api put-bucket-versioning \
  --bucket my-terraform-state-bucket \
  --versioning-configuration Status=Enabled

Enter fullscreen mode Exit fullscreen mode

4. Write the backend block

Now the good part. In your Terraform project, add the backend block with use_lockfile = true. Notice there is no dynamodb_table line anywhere.

terraform {
  backend "s3" {
    bucket       = "my-terraform-state-bucket"
    key          = "global/s3/terraform.tfstate"
    region       = "ap-south-1"
    encrypt      = true
    use_lockfile = true
  }
}

Enter fullscreen mode Exit fullscreen mode

That single use_lockfile = true line is the whole feature.

5. Set the IAM permissions

Because DynamoDB is gone, your permissions get shorter too. The role or user running Terraform now only needs S3 actions, and no DynamoDB actions at all. A minimal policy looks like this.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:ListBucket"],
      "Resource": "arn:aws:s3:::my-terraform-state-bucket"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-terraform-state-bucket/global/s3/terraform.tfstate*"
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

The trailing * on the resource is doing quiet but important work. It matches both the state file and the .tflock lock file, since the lock file shares the same path with .tflock tacked on. One rule covers both.

6. Initialize Terraform

Run init to wire everything up. Terraform reads your backend block and gets the bucket ready.

terraform init

Enter fullscreen mode Exit fullscreen mode

7. Test that the lock works

Run a normal plan and apply.

terraform plan
terraform apply

Enter fullscreen mode Exit fullscreen mode

While the apply is running, open the S3 console or run aws s3 ls on your bucket path. You will see a terraform.tfstate.tflock object appear during the run and vanish once it finishes. If you want to see the lock in action, open a second terminal and start another terraform apply while the first one is still going. The second one will hit that 412 lock error from earlier and refuse to run until the first is done. That is the whole point working exactly as designed.

Moving over if you already use DynamoDB

If you have an existing setup with a DynamoDB lock table, switching is easy and you do not have to do it all in one nervous jump.

The cautious approach is to keep both options set for a short transition period. You can list use_lockfile = true and dynamodb_table at the same time, and Terraform will acquire locks in both places. That way nothing breaks while you and your team catch up.

terraform {
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "global/s3/terraform.tfstate"
    region         = "ap-south-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
    use_lockfile   = true
  }
}

Enter fullscreen mode Exit fullscreen mode

After changing the backend, run init again with the reconfigure flag so Terraform picks up the new settings.

terraform init -reconfigure

Enter fullscreen mode Exit fullscreen mode

Once you are confident everyone is on Terraform 1.10 or newer and the S3 locking is working, remove the dynamodb_table line, run terraform init -reconfigure one more time, and then delete the DynamoDB table you no longer need.

When a lock gets stuck

Every so often a Terraform run crashes halfway, or someone closes their laptop mid apply. When that happens the .tflock file can be left behind even though no operation is actually running. The next person then gets the lock error for a lock nobody holds.

To clear it, grab the lock ID from the error output (the ID field) and run force unlock.

terraform force-unlock 3f6a1c9e-...-b2d4

Enter fullscreen mode Exit fullscreen mode

Use this carefully. Only force unlock when you are sure no real Terraform operation is running anywhere, otherwise you are back to the exact race condition that locking was meant to prevent. If in doubt, ask your team before you unlock.

Why this genuinely reduces complexity

Pull back and look at what you removed. You went from two services to one. There is no DynamoDB table to create, name, tag, watch, or clean up. Your lock now lives in the same bucket as your state, so there is one place to look when you want to understand what is going on.

The bootstrap problem shrinks too. Every Terraform team hits the same chicken and egg puzzle at the start. To use remote state you first need the backend infrastructure to exist, but that infrastructure is itself something you want to manage. With the old approach that meant standing up a bucket and a table before anything else. Now it is just a bucket.

Your permissions shrink. No DynamoDB actions to grant means a shorter IAM policy and fewer things a reviewer has to reason about. Your Terraform code shrinks, because the whole aws_dynamodb_table resource can be deleted. And you cut out a category of quiet failures, like a lock table sitting in the wrong region or a name that does not match the backend config.

Same safety, fewer moving parts. That is a rare and welcome trade.

Wrapping up

State locking has always been one of those things you set up once, forget about, and only remember when it saves you from corrupting shared state. The mechanics used to lean on DynamoDB because S3 could not do the job alone. Now that S3 supports conditional writes, that reason is gone, and Terraform 1.10 gives you a one line switch to take advantage of it.

If you are starting a new project, reach for use_lockfile from day one. If you already have a DynamoDB table doing this work, plan a calm migration, keep both settings on for a bit, then retire the table. Your future self, staring at a much simpler backend block, will thank you.

Let us connect

If you have questions, ran into a snag while trying this, or just want to talk Terraform and AWS, reach out at [email protected]. I am always happy to help someone get unstuck.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다