JSTGTECH
← Back to blog

Terraform S3 native locking: kill your DynamoDB table

6 min read

For as long as I’ve been writing Terraform, “S3 backend” meant “S3 backend plus a DynamoDB table for locking,” full stop. You’d provision a tiny pay-per-request table, grant your CI role dynamodb:GetItem/PutItem/ DeleteItem on it, and never think about it again except when someone asked “wait, why do we have a DynamoDB table for a static site’s infra?” As of Terraform 1.10, that table is no longer required — S3 itself can do the locking, using conditional writes. This site’s own terraform/backend.hcl runs on exactly this setup, no DynamoDB table anywhere in the account. Here’s the history, the config, and the migration path if you’re still running the old way.

Why DynamoDB was ever in the picture

State locking exists to stop two terraform apply runs from racing each other and corrupting state — classic “last writer wins” data loss. S3 never had a native compare-and-swap primitive, so Terraform’s S3 backend piggybacked on DynamoDB’s conditional PutItem (attribute_not_exists) to implement a lock: acquire a lock by writing an item keyed on the state path, release it by deleting the item. It worked well, but it meant every S3 backend needed a second AWS service, a second IAM policy, and a second thing to provision correctly before your very first terraform init — plus a manual dynamodb:DeleteItem to clean up a stuck lock after a killed CI job, which everyone running Terraform in CI has done at least once.

Amazon added conditional writes to S3 itself in August 2024 (If-None-Match / If-Match support on PutObject), and HashiCorp shipped support for using that directly as a locking mechanism in Terraform 1.10 (November 2024) via a new backend argument: use_lockfile. No DynamoDB, no second service, no separate IAM policy — the same S3 permissions your state already needed now cover locking too.

The config

This is the actual backend block from this repo, split the way Terraform backends normally are: the non-secret pieces in a partial config file (terraform/backend.hcl), and the empty backend "s3" {} stanza in versions.tf so nothing hardcoded ends up needing per-environment variables.

# terraform/versions.tf
terraform {
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.60, < 8.0"
    }
  }

  # Remote state in S3 with native locking (Terraform >= 1.10, no DynamoDB).
  # Non-secret values are supplied at init time via backend.hcl.
  backend "s3" {}
}
# terraform/backend.hcl
bucket       = "jstgtech-web-tfstate"
key          = "site/terraform.tfstate"
region       = "us-east-1"
encrypt      = true
use_lockfile = true
terraform init -backend-config=backend.hcl

That’s the whole thing. use_lockfile = true tells the S3 backend to write a <key>.tflock object next to your state object during plan/apply, using a conditional PutObject so a second concurrent run gets a hard failure instead of a silent overwrite, and deletes the lock object when the run finishes (or on terraform force-unlock if a run got killed before it could clean up). No dynamodb_table argument anywhere — the S3 backend has supported that argument for years for the old locking path, and you can technically still set both during a transition (more on that below), but a fresh setup like this one just doesn’t need it.

Gotchas that’ll actually bite you

  • Versioning on the state bucket is still mandatory, and separate from ** locking.** Locking stops concurrent writes; versioning is what saves you when someone runs apply against a bad plan or you need to roll back a corrupted state file. use_lockfile doesn’t touch this at all — you still need aws_s3_bucket_versioning with status = "Enabled" on the bucket, the same as the DynamoDB-locking days. This repo’s bootstrap layer (terraform/bootstrap/main.tf) sets it explicitly:
 resource "aws_s3_bucket_versioning" "tfstate" {
    bucket = aws_s3_bucket.tfstate.id
    versioning_configuration {
      status = "Enabled"
    }
  }
  • Your IAM role needs write permissions it might not already have. The lock file is a real S3 object, so whatever role runs Terraform needs s3:PutObject and s3:DeleteObject on the state bucket — most roles that could already read/write state already have this, but if you’d scoped a role down to s3:GetObject + s3:PutObject on just the state key, tighten it to cover the <key>.tflock path too (or just the whole prefix, which is simpler and what most setups do — s3:* scoped to the bucket ARN, not wildcarded across accounts).
  • The backend block cannot reference variables, locals, or anything ** computed — this predates native locking but people relearn it every time.** You cannot do backend "s3" { bucket = var.state_bucket }. That’s why backend "s3" {} is empty in versions.tf and everything lives in backend.hcl, passed at init time with -backend-config. If you have multiple environments, you’ll have multiple .hcl files (or a templated one generated by CI before init), not variables inside the block.
  • use_lockfile needs Terraform ≥ 1.10, checked hard. If someone on your team is still on 1.9 (or an old pinned CI container), init will just error on the unrecognized argument. Bump required_version in the same PR that adds use_lockfile so the mismatch fails loud at init instead of someone silently running an older binary against a backend config it doesn’t understand.
  • Stuck locks are unlocked differently now. With DynamoDB you’d aws dynamodb delete-item the lock row by hand, or use terraform force-unlock <LOCK_ID> which did the same thing under the hood. With native locking, force-unlock still works — Terraform reads the lock ID from the .tflock object’s contents — but if you’re ever debugging by hand, you’re looking for an S3 object, not a DynamoDB item, and it’s named <key>.tflock next to your actual state object in the same bucket.

Migrating an existing DynamoDB-locked backend

If you’ve got a working dynamodb_table setup today, don’t just delete the table and flip use_lockfile on in the same change — do it in two steps so you’re never in a state where in-flight infra changes could hit a locking config mismatch:

  1. **Add use_lockfile = true to backend.hcl alongside the existing dynamodb_table entry**, then run:
   terraform init -reconfigure

-reconfigure (not -migrate-state) is correct here — you’re not moving state to a new bucket/key, you’re just changing backend configuration on the same backend type. Terraform will pick up native locking going forward. At this point both mechanisms are technically configured, but Terraform only uses one lock mechanism per backend version — as of the 1.10–1.13 line, setting both dynamodb_table and use_lockfile together is explicitly supported as a transition state precisely for this migration.

  1. Run a real plan and apply to confirm the new lock path works — watch for the .tflock object appearing in the state bucket during the run (aws s3 ls s3://your-tfstate-bucket/ --recursive | grep tflock while it’s running, or just check after a plan that holds the lock long enough).
  2. Once you’re confident, remove dynamodb_table from backend.hcl and run terraform init -reconfigure again. Nothing about your state file changes in this step — you’re purely dropping a backend config key.
  3. Delete the DynamoDB table — but not immediately. Leave it a week or two after step 3 in case you need to roll a teammate’s stale local config back, then terraform state rm/destroy it via whatever provisioned it originally (if the table itself was Terraform-managed in a bootstrap layer, remove the resource block and apply; if it was created by hand, just delete it from the console or CLI). For a low-traffic project this is maybe $1–2/month in the pay-per-request billing mode most people use for lock tables, so the savings are more about **one fewer resource to provision, IAM-scope, and explain to the next person reading your Terraform** than meaningful dollars — the real win is operational, not financial.

When DynamoDB locking still earns its keep

I wouldn’t rip it out reflexively everywhere:

  • You’re pinned below Terraform 1.10 for reasons outside your control (an older provider requiring an old core version, an internal policy that hasn’t approved the upgrade yet). use_lockfile simply isn’t available.
  • Multiple tools lock against the same state outside Terraform’s own CLI — e.g., a custom automation script or a different IaC tool cooperating on the same DynamoDB lock table by convention. S3 native locking is Terraform-backend-specific; nothing else in that ecosystem understands .tflock objects the way tools built around the DynamoDB API might.
  • You’re on OpenTofu and haven’t checked whether your pinned version has the equivalent support yet — OpenTofu forked before this landed and added it on its own timeline, so don’t assume version-number parity with Terraform means feature parity here.

For a single-account, single-tool setup like this site’s — GitHub Actions running terraform plan/apply via OIDC, nothing else touching state — none of those apply, which is exactly why terraform/backend.hcl here has no dynamodb_table line at all.

Related posts