---
title: Terraform S3 State Locking Without DynamoDB (2026)
description: Enable Terraform S3 native state locking with use_lockfile - no DynamoDB table required. Set up the backend, migrate an existing project, lock down IAM.
url: https://nerdleveltech.com/terraform-s3-native-state-locking-tutorial
language: en
date: '2026-05-25'
category: cloud-devops
tags:
  - terraform
  - s3
  - state locking
  - dynamodb
  - aws
  - infrastructure as code
  - devops
  - backend configuration
markdown_url: https://nerdleveltech.com/terraform-s3-native-state-locking-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/terraform-s3-native-state-locking-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Terraform S3 State Locking Without DynamoDB (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.[^1][^2] 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_lockfile` argument for native locking
- Initialize the backend and confirm the `.tflock` lock 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_lockfile` is 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 with `terraform version`.
- **An AWS account and the AWS CLI v2**, configured with credentials (`aws configure`, environment variables, or AWS IAM Identity Center). Confirm with `aws sts get-caller-identity`.
- **IAM permissions** to create an S3 bucket and put objects in it.
- Basic familiarity with `terraform init`, `plan`, and `apply`. If remote state is new to you, the [infrastructure as code fundamentals guide](https://nerdleveltech.com/infrastructure-as-code-iac-fundamentals-a-complete-2025-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:

```bash
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]

```bash
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:

```bash
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:

```bash
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]

```hcl
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:

- `key` is the object path inside the bucket. The lock file is derived from it: Terraform writes `global/s3-locking-demo/terraform.tfstate.tflock` — the same path with a `.tflock` suffix.[^3]
- `encrypt = true` asks Terraform to encrypt the state and lock objects it uploads, on top of the bucket-level default from Step 1.
- `use_lockfile = true` enables native locking. There is no `dynamodb_table` argument anywhere in this configuration.
- Backend blocks cannot use variables or interpolation, so the values are literal. For environment-specific values, use [partial configuration](https://nerdleveltech.com/infrastructure-as-code-iac-fundamentals-a-complete-2025-guide) and pass `-backend-config` at 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:

```bash
terraform init
```

```text
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:

```bash
terraform apply
```

```text
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:

```bash
aws s3 ls "s3://$TF_STATE_BUCKET/global/s3-locking-demo/"
```

```text
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:

```bash
terraform apply -replace="random_pet.server"
```

```text
  # 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:

```bash
terraform plan
```

```text
╷
│ 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:

```bash
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:

```hcl
  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`:

```hcl
  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:

```bash
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:

```hcl
  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
  }
```

```bash
terraform init -reconfigure
```

**Phase 3 — clean up.** With every project migrated, delete the now-unused table and the IAM permissions that referenced it:

```bash
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]

```json
{
  "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:

```bash
aws s3api head-object \
  --bucket "$TF_STATE_BUCKET" \
  --key "global/s3-locking-demo/terraform.tfstate" \
  --query 'ServerSideEncryption' --output text
```

```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`.

```bash
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.[^2][^3]

## 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_prefix` and a Terraform workspace per stage.
- Move the backend bootstrap into automation so the bucket itself is reproducible.
- Wire `terraform plan`/`apply` into CI with `-lock-timeout` so back-to-back jobs queue gracefully. The patterns in [deploying a production app to Fly.io](https://nerdleveltech.com/deploy-bun-hono-fly-io-production-tutorial) and [migrating Kubernetes ingress to the Gateway API](https://nerdleveltech.com/migrate-ingress-to-kubernetes-gateway-api-tutorial) 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.

## Related reads

- [Migrate Terraform to OpenTofu in 2026: Encrypt Your State](https://nerdleveltech.com/migrate-terraform-to-opentofu-state-encryption-tutorial)
- [AWS CDK Testing in TypeScript: Test Before Deploy 2026](https://nerdleveltech.com/aws-cdk-testing-typescript)

[^1]: Terraform v1.10.0 release (2024-11-27) — introduced experimental S3 native state locking. https://github.com/hashicorp/terraform/releases/tag/v1.10.0
[^2]: Terraform v1.11.0 release (2025-02-27) — `use_lockfile` generally available; DynamoDB-based locking deprecated. https://github.com/hashicorp/terraform/releases/tag/v1.11.0
[^3]: "Backend Type: s3" — HashiCorp Developer documentation (state locking, `use_lockfile`, `.tflock`, IAM permissions, migration). https://developer.hashicorp.com/terraform/language/backend/s3
[^4]: hashicorp/random provider — Terraform Registry. https://registry.terraform.io/providers/hashicorp/random/latest
[^5]: "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/
