Terraform S3 native locking: kill your DynamoDB table
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
applyagainst a bad plan or you need to roll back a corrupted state file.use_lockfiledoesn’t touch this at all — you still needaws_s3_bucket_versioningwithstatus = "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:PutObjectands3:DeleteObjecton the state bucket — most roles that could already read/write state already have this, but if you’d scoped a role down tos3:GetObject+s3:PutObjecton just the state key, tighten it to cover the<key>.tflockpath 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
backendblock cannot reference variables, locals, or anything ** computed — this predates native locking but people relearn it every time.** You cannot dobackend "s3" { bucket = var.state_bucket }. That’s whybackend "s3" {}is empty inversions.tfand everything lives inbackend.hcl, passed atinittime with-backend-config. If you have multiple environments, you’ll have multiple.hclfiles (or a templated one generated by CI beforeinit), not variables inside the block. use_lockfileneeds Terraform ≥ 1.10, checked hard. If someone on your team is still on 1.9 (or an old pinned CI container),initwill just error on the unrecognized argument. Bumprequired_versionin the same PR that addsuse_lockfileso the mismatch fails loud atinitinstead 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-itemthe lock row by hand, or useterraform force-unlock <LOCK_ID>which did the same thing under the hood. With native locking,force-unlockstill works — Terraform reads the lock ID from the.tflockobject’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>.tflocknext 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:
- **Add
use_lockfile = truetobackend.hclalongside the existingdynamodb_tableentry**, 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.
- Run a real
planandapplyto confirm the new lock path works — watch for the.tflockobject appearing in the state bucket during the run (aws s3 ls s3://your-tfstate-bucket/ --recursive | grep tflockwhile it’s running, or just check after aplanthat holds the lock long enough). - Once you’re confident, remove
dynamodb_tablefrombackend.hcland runterraform init -reconfigureagain. Nothing about your state file changes in this step — you’re purely dropping a backend config key. - 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_lockfilesimply 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
.tflockobjects 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.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Cloud roundup: macOS Screen Sharing bug now under attack
A patched macOS Screen Sharing flaw is being exploited to plant crypto miners, a Windows Defender bypass has no fix yet, and EC2 gets built-in app health checks.
Cutting NAT gateway costs with VPC endpoints that actually help
How gateway and interface VPC endpoints replace NAT gateway traffic for AWS API calls, what they cost instead, and which traffic still has to go through NAT.
Cloud roundup: S3 finally names the policy that denied you
AWS S3 access-denied errors now name the exact policy ARN, Client VPN gets a scriptable CLI, and OpenAI ships authorized offensive-security models on Bedrock.