AwsCLI icon
Docker icon
Log icon
If icon
Pause icon
TerraformCLI icon
Fail icon
SlackIncomingWebhook icon
Schedule icon

Release a Stale Terraform State Lock with a Guarded force-unlock

Detect an orphaned Terraform state lock in DynamoDB, prove it is not live, then release it with a gated force-unlock and a re-read that fails the run.

Categories
Infrastructure

A killed terraform apply leaves its lock record behind in the S3 backend's DynamoDB lock table. Every later plan and apply then stops at Error acquiring the state lock, so the stack is frozen until somebody copies the lock ID out of the error message and runs terraform force-unlock by hand. This blueprint does that work with the checks a human would skip at 2am.

Safety

force-unlock is the one Terraform command that can make things worse. Releasing a lock that is actually live lets two applies write the same state file, which loses resources from state, leaves real infrastructure orphaned in the cloud account plus forces a manual terraform import for every lost address. So the unlock sits behind four gates that all have to agree: the lock has to be older than min_lock_age_minutes, its ID must not have changed between two samples, auto_remediate has to be true with dry_run false, plus a human has to approve the Pause. The flow re-reads the lock after the approval and refuses when the ID moved, because the lock being held then is not the lock that was approved.

The flow never runs terraform plan as a probe. plan takes the state lock itself, so a probe would create the record it is meant to be hunting.

How it works

  1. read_lock_record (io.kestra.plugin.aws.cli.AwsCLI) reads the lock item from DynamoDB with a consistent read, waits liveness_sample_seconds, then reads it again. The S3 backend keys its lock on <bucket>/<key>, so backend_bucket plus state_key identify the record. The Info attribute carries the lock ID, the operation, who held it plus the creation time, which is where the age comes from. A read error exits 2 rather than reporting a clean backend.
  2. log_detection (io.kestra.plugin.core.log.Log) prints the findings count, the lock ID, the operation, the holder, the age in minutes plus whether the ID changed between samples.
  3. backend_is_clean (io.kestra.plugin.core.flow.If) ends the run with 0 findings when no lock record exists.
  4. lock_held branches into three decisions. refuse_young_lock leaves anything inside the grace window alone, because a long apply holds its lock for minutes. refuse_moving_lock leaves the lock alone when the ID changed between the two samples, which means a pipeline is actively taking the lock. stale_lock is the only branch that goes further.
  5. unlock_gate checks auto_remediate and not dry_run. The else branch logs the exact force-unlock command it would have run plus stops there, which is the default posture of this flow.
  6. approve_unlock (io.kestra.plugin.core.flow.Pause) holds for approval_window. An expired window resumes with no answer, which reads as declined.
  7. recheck_lock reads the lock again after the approval. same_lock_check compares the ID against the one that was approved plus refuses if it moved.
  8. force_unlock (io.kestra.plugin.terraform.cli.TerraformCLI) writes a backend-only backend.tf, runs terraform init -input=false, then terraform force-unlock -force <id>. It carries allowFailure: true on purpose, because a green CLI task is not evidence that the lock went away.
  9. verify_lock_released reads the lock record one more time. unlock_landed fails the run through io.kestra.plugin.core.execution.Fail when the record is still there, so a backend that stayed wedged never reports success.
  10. The errors block posts to Slack through io.kestra.plugin.slack.notifications.SlackIncomingWebhook with the backend path plus the execution id.

What you get

  • A stale lock cleared without anyone hand copying a UUID out of a failed pipeline log.
  • A liveness test that uses the only signal the backend carries, which is whether the lock ID is moving.
  • An audit trail per execution: the lock ID, the operation, the holder, the age, who approved plus the state of the record afterwards.
  • A run that fails loudly when the unlock did not land, instead of a green task over a still frozen backend.
  • Report-only by default, so the first thing you get is an inventory of wedged backends.

Who it's for

Platform plus SRE teams running Terraform through CI against an S3 backend with DynamoDB locking, where a cancelled job or a dead runner wedges the whole stack until somebody with credentials intervenes.

Why orchestrate this with Kestra

The unlock is cheap. The decision around it is not. Kestra holds the age window, the two-sample liveness test, the approval, the re-check after the approval plus the re-read that fails the run in one declarative file, with every value logged per execution. A shell script in CI can run force-unlock, but it has nowhere to park a human approval plus no record of what the lock looked like before the release.

Tested end to end

Run against LocalStack with a real S3 backend plus a real DynamoDB lock table, with endpoint_url pointed at the LocalStack endpoint:

  • Clean backend: 0 findings, backend_is_clean reports nothing to release.
  • Stale lock seeded as a killed apply leaves it: detection reports 1 finding with the lock ID, the operation, the holder plus an age in minutes.
  • dry_run=true: the exact force-unlock command is logged. The lock record is still in DynamoDB afterwards.
  • auto_remediate=true with dry_run=false plus an approved Pause: terraform force-unlock releases the lock, the re-read reports 0 records left. terraform plan failed with Error acquiring the state lock before the run plus succeeded after it.
  • Declined Pause: the lock record is unchanged.
  • Unlock blocked by a broken backend: the re-read still sees the record plus the run fails.

Prerequisites

Secrets

  • AWS_ACCESS_KEY_ID: credentials that can read the lock table plus delete the lock item.
  • AWS_SECRET_ACCESS_KEY: the matching secret key.
  • SLACK_WEBHOOK_URL: incoming webhook for the failure alert.

Inputs

  • dry_run (BOOL, default true): report only.
  • auto_remediate (BOOL, default false): allow the unlock.
  • backend_bucket (STRING, default tfstate): bucket holding the state object.
  • state_key (STRING, default prod/terraform.tfstate): state object key.
  • lock_table (STRING, default terraform-locks): DynamoDB lock table.
  • min_lock_age_minutes (INT, default 30): grace window before a lock counts as stale.
  • liveness_sample_seconds (INT, default 10): gap between the two lock samples.
  • approval_window (DURATION, default PT30M): how long the Pause waits.
  • aws_region (STRING, default us-east-1): region of the bucket plus the table.
  • endpoint_url (STRING, default empty): endpoint override for LocalStack or an S3 compatible gateway.
  • terraform_image (STRING, default hashicorp/terraform:1.9): image used for the unlock.
  • access_key_id / secret_access_key (STRING, default empty): override the AWS secrets for a sandbox run.

Quick start

  1. Add AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY plus SLACK_WEBHOOK_URL as Kestra secrets.
  2. Set backend_bucket, state_key, lock_table plus aws_region to the backend you want watched.
  3. Run it with the defaults. It reads the lock record twice then reports. Nothing is written.
  4. Read the log_detection line. A finding carries the lock ID, the operation, the holder plus the age.
  5. When the lock really is orphaned, re-run with auto_remediate=true plus dry_run=false, then approve the Pause from the UI or through POST /api/v1/main/executions/{id}/actions/resume.
  6. Enable hourly_lock_sweep once the inputs are right. It stays in report mode.

How to extend

  • Point terraform_image at an OpenTofu image to run the same guard against a tofu managed backend.
  • Swap the Slack task for PagerDuty or Opsgenie when a wedged backend should page.
  • Add an io.kestra.plugin.aws.s3.List call on the state key before the unlock, so a state object written after the lock was taken blocks the release.
  • Raise min_lock_age_minutes above your longest apply, then drop liveness_sample_seconds to zero on a backend no pipeline touches.
  • Loop the whole flow over several backends with io.kestra.plugin.core.flow.Subflow plus a list of bucket and key pairs.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.