Skip to content

Blog

How do I set up Terraform remote state with locking, and do I still need DynamoDB?

·

Remote state keeps one shared copy of your state file, and a lock stops two runs writing it at once. On S3, Terraform 1.11 and OpenTofu 1.10 can lock in the bucket itself with use_lockfile, so new setups need no DynamoDB table. GCS and azurerm lock natively; the http backend locks only if its server implements it.

The Terraform and OpenTofu facts below were read from their documentation and changelogs on 10 November 2026. Where something is ours, it says so.

What is remote state, and what does a state lock protect against?

Remote state is the state file kept in a shared backend rather than on one laptop, so every person and every CI run reads and writes the same copy. HashiCorp’s documentation describes it as state written to a remote data store that can be shared between all members of a team.

A lock protects that copy from concurrent writers. Without one, two applies can start from the same state, each record its own result, and the later write wins: the cloud then holds resources that the state file no longer lists. With one, locking happens automatically on every operation that could write state, and if Terraform cannot take the lock it does not continue. Not every backend can lock, so the backend’s own page is the place to check before you choose one.

What a lock does not do is review the change. It makes runs take turns; it does not say whether the second run should have happened, and it is not a backup. A bucket you run still needs versioning turned on, and that is the first place the choice of backend shows up.

Does the S3 backend lock natively now (use_lockfile), and from which versions?

Yes. The S3 backend can lock with a file in the same bucket, switched on by use_lockfile. Terraform first shipped it in 1.10 and made it generally available in 1.11, released on 27 February 2025. OpenTofu has had it since 1.10.

ToolNative S3 lockWhat its documentation says about DynamoDB
Terraform1.10 first; generally available in 1.11Deprecated, and to be removed in a future minor version
OpenTofu1.10No plans to deprecate either option
terraform { backend "s3" { bucket = "acme-tfstate" key = "prod/network/terraform.tfstate" region = "eu-west-1" encrypt = true use_lockfile = true } }

The lock is an object named after the state key with .tflock on the end, so the example above locks prod/network/terraform.tfstate.tflock. The credentials that run Terraform need s3:GetObject, s3:PutObject and s3:DeleteObject on that lock object, which is more than the state file itself needs, since Terraform does not delete state. OpenTofu describes the mechanism as a conditional write with an If-None-Match header. On AWS that is what you want to hear; on any other store that speaks the S3 protocol, run two plans at once and confirm the second one is refused before you trust it.

To move an existing setup, keep dynamodb_table and add use_lockfile = true. Both documentations say the S3 and DynamoDB arguments can be set together, and that a lock is then taken from both. Run that for a while, then remove the table argument. Upgrade every machine that runs the configuration first: a version that does not know the argument will not accept the block. Changing the backend block means running init -reconfigure once on each checkout before the next plan, since nothing about where the state lives has moved.

How do GCS, azurerm and the http backend lock?

GCS and azurerm lock without anything extra to set up, and the http backend locks only if you point it at lock endpoints.

BackendLockingWhat to do
gcsSupported by the backendTurn on object versioning on the bucket, which HashiCorp's page highly recommends
azurermSupported, using Azure Blob Storage's native capabilitiesNothing to configure for the lock
httpOff unless lock_address and unlock_address are setYour server must implement them

For http, the backend sends LOCK and UNLOCK requests by default, and expects a 423 or 409 answer, carrying the holder’s details, when the lock is already taken. A plain file server behind an http backend gives you shared state and no lock at all.

terraform { backend "http" { address = "https://state.example.com/prod" lock_address = "https://state.example.com/prod/lock" unlock_address = "https://state.example.com/prod/lock" } }

In CI, a lock is the last line of defence, not the first. A concurrency group stops two runs of the same workflow starting together. By GitHub’s documentation, a group has at most one running run and, by default, one pending one, with a newer pending run replacing the older. The lock still covers a laptop, or another pipeline, that the group cannot see, and -lock-timeout makes a run retry for a while rather than fail at once.

name: apply on: push: branches: [main] concurrency: group: tofu-apply-prod cancel-in-progress: false # never cancel an apply that has started permissions: contents: read jobs: apply: runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: opentofu/setup-opentofu@a1320f892987e89d278cc92dc5adc984fb93aca4 # v2.0.2 - run: tofu init -input=false - run: tofu apply -auto-approve -input=false -lock-timeout=10m

Both actions are pinned to a commit SHA, as the security checklist recommends. Give the job cloud credentials the way your provider recommends; none are shown here.

How do I release a stuck lock safely (force-unlock), and when must I not?

Release it with force-unlock and the lock ID, but only after you have confirmed that nothing still holds the lock. The command takes the ID that the failed run printed, and it does not touch your infrastructure.

The order that keeps you out of trouble is below.

First, read the error. It says what the lock was taken for, who took it, when, and its ID.

Second, find that run or person. A job cancelled, or a laptop closed, before Terraform could release the lock is the usual orphan. If a run is still going, let it finish, or retry with -lock-timeout.

Third, and only when the holder is certainly dead, run tofu force-unlock LOCK_ID. Add -force to skip the confirmation prompt.

Last, run a plan and read it before applying. An apply that died halfway may have created things your state does not list.

Do not force-unlock while another apply is genuinely running: two applies against one state is exactly what the lock exists to prevent. HashiCorp’s documentation is blunt that the command is for unlocking your own lock after automatic unlocking failed. And do not reach for -lock=false to get past a lock; the documentation calls it dangerous when others might run against the same state.

On a runners.io workspace the same rule applies, with buttons. A run holds the workspace lock from the moment it is queued until it is done, including while it waits for someone to confirm the apply, so a locked workspace is usually a run to apply, discard or cancel. An owner or an admin can press Unlock, and Force unlock is for the case where nothing is running and the lock is still held. The troubleshooting page has the details.

When is a hosted state backend worth it over a bucket you run?

It is worth it when you would rather not own the bucket’s versioning, access policy and lock clean-up, or when you want to see and undo what changed in state. It is not worth it if you need your own encryption keys or a region you choose, and a bucket you already govern well is a fine answer.

A bucket you runHosted state on runners.io
LockingYou configure it (use_lockfile on S3)Built in; a run holds the workspace lock while it is queued and running
HistoryObject versioning you turn on and read by handEvery state version kept, with a diff and a rollback that writes a new version
A deleted versionWhatever your bucket policy keepsRestorable for 30 days
Keys and regionYours to chooseNo customer-managed keys; state is kept in Ireland
CostYour storage bill$0.50/GB-month, wherever your runs execute

Pointing a configuration at it is one block and a token. The workspace is created on first use.

terraform { cloud { hostname = "tf.runners.io" organization = "acme" workspaces { name = "production" } } }

You can keep the state here and run the plan and apply on your own machine or in your own CI; the state price is the same either way. That is how we run our own infrastructure: its state is on a runners.io workspace, and the plans and applies run on ours. One lesson from moving it: a state push into a cloud backend once exited 0 after a schema error, having written a fresh lineage. We now check a plan afterwards rather than trust the exit code, and the guide to moving state has the safe path.

If you let runs execute here too, they run OpenTofu, which is why the licence post exists, and the compatibility page lists the few constructs it cannot run. The quickstart takes you from nothing to a first plan, and the Terraform page has the whole offer.

Run your CI and your infrastructure on one bill

Connect GitHub

Card required. You pay only for the minutes your jobs run.