Error Medic

Terraform State Locked: Fix 'Error locking state' and Access Denied Issues

Fix Terraform state lock errors fast. Step-by-step guide to diagnose and resolve terraform state locked, access denied, and timeout issues with real commands.

Last updated:
Last verified:
1,955 words
Key Takeaways
  • Terraform state locks are created in DynamoDB (AWS), Azure Blob Storage, or GCS to prevent concurrent writes — a stale lock from a crashed process is the most common root cause
  • Access denied errors on lock operations usually mean the IAM role or service account lacks s3:PutObject, dynamodb:PutItem, or equivalent permissions on the backend resources
  • The fastest safe fix is `terraform force-unlock <LOCK_ID>` after confirming no other process is actively running — never delete the lock record manually without first verifying the ID
  • Timeout errors during lock acquisition often signal network instability, DynamoDB throttling, or a misconfigured backend endpoint — check CloudWatch metrics before forcing
  • Always run `terraform plan` after unlocking to validate state consistency before applying any changes
Fix Approaches Compared
MethodWhen to UseTimeRisk
terraform force-unlock <ID>Confirmed stale lock, no active run< 1 minLow — uses official CLI
Delete DynamoDB lock item via AWS Consoleforce-unlock fails due to permissions2–5 minMedium — manual, easy to delete wrong item
Fix IAM / service account permissionsAccess denied on lock create/release5–15 minLow — additive change
Migrate backend to new workspaceState file corrupted or lock record lost15–30 minHigh — data migration risk
Increase DynamoDB provisioned capacityThrottling causes repeated lock timeouts5 minLow — cost increase only
Run terraform init -reconfigureBackend config changed, init state stale2 minLow — safe re-init

Understanding the Terraform State Lock Error

Terraform uses a locking mechanism to protect state files from simultaneous writes, which would corrupt shared infrastructure state. When a terraform apply or terraform plan starts, it attempts to acquire a lock on the backend. If the lock cannot be acquired — or if a previous process crashed without releasing it — you will see one of the following errors:

Error: Error locking state: Error acquiring the state lock: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:        a1b2c3d4-e5f6-7890-abcd-ef1234567890
  Path:      s3://my-bucket/terraform.tfstate
  Operation: OperationTypeApply
  Who:       user@hostname
  Version:   1.6.0
  Created:   2024-11-12 09:34:21.123456789 +0000 UTC
  Info:

Or for access-related failures:

Error: Failed to get existing workspaces: S3 bucket does not exist.

Error: error using credentials to get account ID: operation error STS: GetCallerIdentity,
https response error StatusCode: 403, RequestID: ..., api error AccessDenied: Access denied

Or for permission denied on the lock table:

Error: Error acquiring the state lock: 2 errors occurred:
  * ResourceNotFoundException: Requested resource not found
  * AccessDeniedException: User: arn:aws:iam::123456789012:user/deploy is not authorized
    to perform: dynamodb:PutItem on resource: arn:aws:dynamodb:us-east-1:...

Step 1: Identify the Error Category

Before acting, categorize the failure:

Category A — Stale Lock (process crashed or CI job killed) The lock ID is printed in the error output. No active Terraform process is running. This is the most common scenario after a CI pipeline timeout or a laptop lid-close during apply.

Category B — Access Denied / Permission Denied The error mentions AccessDeniedException, 403, or permission denied. The process cannot even create or check the lock. This is an IAM or credential issue.

Category C — Timeout / Network The error says RequestError: send request failed or context deadline exceeded. The process can reach the backend but gets no timely response. This is infrastructure-level.

Category D — Corrupt State or Missing Lock Table The error mentions ResourceNotFoundException or the state file is malformed. The DynamoDB table or GCS/Azure equivalent does not exist or the state JSON is invalid.


Step 2: Diagnose the Root Cause

For Stale Locks (Category A)

First, confirm no other process holds the lock legitimately:

# Check for running Terraform processes on CI/CD
# In GitHub Actions: check active workflow runs
gh run list --workflow=terraform.yml --status=in_progress

# On AWS, check the DynamoDB lock table directly
aws dynamodb get-item \
  --table-name terraform-state-locks \
  --key '{"LockID": {"S": "my-bucket/terraform.tfstate"}}' \
  --region us-east-1

The response will show WHO created the lock and when. If the timestamp is older than your longest possible apply window (e.g., > 60 minutes) or the WHO field points to a hostname that no longer exists, it is safe to force-unlock.

For Access Denied (Category B)
# Verify current credentials
aws sts get-caller-identity

# Test specific permissions needed for S3 backend + DynamoDB locking
aws s3 ls s3://your-terraform-state-bucket/
aws dynamodb describe-table --table-name terraform-state-locks

# Check what policy is attached
aws iam get-user --user-name deploy-user
aws iam list-attached-user-policies --user-name deploy-user
aws iam list-user-policies --user-name deploy-user

The minimum required IAM permissions for an S3+DynamoDB backend are:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-terraform-state-bucket",
        "arn:aws:s3:::your-terraform-state-bucket/*"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:DeleteItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/terraform-state-locks"
    }
  ]
}
For Timeouts (Category C)
# Check DynamoDB table metrics in CloudWatch
aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB \
  --metric-name ThrottledRequests \
  --dimensions Name=TableName,Value=terraform-state-locks \
  --start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --period 300 \
  --statistics Sum

# Test network connectivity to S3 endpoint
curl -v https://s3.us-east-1.amazonaws.com/ 2>&1 | head -20

Step 3: Apply the Fix

Fix A — Force-Unlock a Stale Lock
# Use the Lock ID from the error output
terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890

Terraform will prompt for confirmation. After unlocking, immediately run:

terraform plan

This confirms state integrity before proceeding.

Fix B — Manual DynamoDB Delete (when force-unlock fails)
# Replace the LockID value with your exact state path
aws dynamodb delete-item \
  --table-name terraform-state-locks \
  --key '{"LockID": {"S": "your-bucket/path/to/terraform.tfstate"}}' \
  --region us-east-1

Warning: Double-check the LockID string exactly matches — deleting the wrong item will have no effect but deleting the wrong table item in a multi-workspace setup can corrupt another team's lock.

Fix C — Resolve Access Denied

Attach the corrected policy:

# Create the policy document
cat > terraform-backend-policy.json << 'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {"Effect": "Allow", "Action": ["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:ListBucket"], "Resource": ["arn:aws:s3:::YOUR_BUCKET","arn:aws:s3:::YOUR_BUCKET/*"]},
    {"Effect": "Allow", "Action": ["dynamodb:GetItem","dynamodb:PutItem","dynamodb:DeleteItem"], "Resource": "arn:aws:dynamodb:REGION:ACCOUNT_ID:table/YOUR_TABLE"}
  ]
}
EOF

aws iam put-user-policy \
  --user-name deploy-user \
  --policy-name TerraformBackendAccess \
  --policy-document file://terraform-backend-policy.json
Fix D — Create Missing DynamoDB Table

If the lock table was accidentally deleted:

aws dynamodb create-table \
  --table-name terraform-state-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region us-east-1

Then re-run terraform init to re-register the backend.


Step 4: Prevent Recurrence

  1. Set pipeline timeouts: In CI/CD, always set a hard timeout and ensure the cleanup step runs terraform force-unlock with the lock ID captured at apply-start.
  2. Use workspace isolation: Separate workspaces (or separate state files) per environment prevent a dev lock from blocking production.
  3. Enable DynamoDB auto-scaling: Prevents throttling-induced lock timeouts at scale.
  4. Tag lock table with cost allocation tags: Makes it easier to identify ownership and avoid accidental deletion.
  5. Monitor for stale locks: Set a CloudWatch alarm on DynamoDB items older than N minutes using a Lambda or EventBridge rule.

Frequently Asked Questions

bash
#!/usr/bin/env bash
# terraform-lock-doctor.sh
# Diagnose and optionally fix Terraform state lock issues
# Usage: ./terraform-lock-doctor.sh <s3-bucket> <lock-table> <region> [state-path]

set -euo pipefail

BUCKET="${1:-my-terraform-state}"
TABLE="${2:-terraform-state-locks}"
REGION="${3:-us-east-1}"
STATE_PATH="${4:-terraform.tfstate}"

echo "=== Terraform Lock Doctor ==="
echo "Bucket: $BUCKET | Table: $TABLE | Region: $REGION"
echo ""

# 1. Verify current AWS identity
echo "[1/5] Checking AWS credentials..."
aws sts get-caller-identity --region "$REGION" 2>&1 || {
  echo "ERROR: Cannot authenticate. Check AWS_PROFILE, AWS_ACCESS_KEY_ID, or OIDC token."
  exit 1
}

# 2. Check S3 bucket access
echo ""
echo "[2/5] Testing S3 bucket access..."
aws s3 ls "s3://${BUCKET}/" --region "$REGION" > /dev/null 2>&1 && \
  echo "  OK: S3 bucket accessible" || \
  echo "  FAIL: Cannot list S3 bucket — check s3:ListBucket permission"

# 3. Check DynamoDB table
echo ""
echo "[3/5] Inspecting DynamoDB lock table..."
TABLE_STATUS=$(aws dynamodb describe-table \
  --table-name "$TABLE" \
  --region "$REGION" \
  --query 'Table.TableStatus' \
  --output text 2>&1) || TABLE_STATUS="NOT_FOUND"

if [[ "$TABLE_STATUS" == "NOT_FOUND" ]]; then
  echo "  FAIL: DynamoDB table '$TABLE' not found in $REGION"
  echo "  Fix: aws dynamodb create-table --table-name $TABLE --attribute-definitions AttributeName=LockID,AttributeType=S --key-schema AttributeName=LockID,KeyType=HASH --billing-mode PAY_PER_REQUEST --region $REGION"
else
  echo "  OK: Table status = $TABLE_STATUS"
fi

# 4. Scan for existing locks
echo ""
echo "[4/5] Scanning for active locks..."
LOCKS=$(aws dynamodb scan \
  --table-name "$TABLE" \
  --region "$REGION" \
  --output json 2>/dev/null || echo '{"Items":[], "Count":0}')

COUNT=$(echo "$LOCKS" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Count',0))")
echo "  Found $COUNT lock record(s):"

if [[ "$COUNT" -gt 0 ]]; then
  echo "$LOCKS" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for item in d.get('Items', []):
    lock_id = item.get('LockID', {}).get('S', 'N/A')
    info_raw = item.get('Info', {}).get('S', '{}')
    try:
        info = json.loads(info_raw)
    except Exception:
        info = {}
    print(f\"  LockID : {lock_id}\")
    print(f\"  Who    : {info.get('Who', 'unknown')}\")
    print(f\"  Created: {info.get('Created', 'unknown')}\")
    print(f\"  Op UUID: {info.get('ID', 'unknown')}\")
    print()
"
fi

# 5. Throttling check
echo "[5/5] Checking DynamoDB throttle metrics (last 60 min)..."
THROTTLED=$(aws cloudwatch get-metric-statistics \
  --namespace AWS/DynamoDB \
  --metric-name ThrottledRequests \
  --dimensions Name=TableName,Value="$TABLE" \
  --start-time "$(date -u -d '60 minutes ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-60M +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 3600 \
  --statistics Sum \
  --region "$REGION" \
  --query 'Datapoints[0].Sum' \
  --output text 2>/dev/null || echo "N/A")

echo "  ThrottledRequests (1h sum): $THROTTLED"
[[ "$THROTTLED" != "None" && "$THROTTLED" != "N/A" && $(echo "$THROTTLED > 0" | bc -l 2>/dev/null || echo 0) -eq 1 ]] && \
  echo "  WARNING: Throttling detected — consider switching DynamoDB to PAY_PER_REQUEST billing"

echo ""
echo "=== Diagnosis complete ==="
echo "If a stale lock was found above, run:"
echo "  terraform force-unlock <Op UUID from above>"
echo "Then verify state with: terraform plan"
E

Error Medic Editorial

Error Medic Editorial is a team of senior SREs and DevOps engineers with combined experience across AWS, GCP, Azure, and Kubernetes production environments. Our troubleshooting guides are written from real incident postmortems and reviewed against current upstream documentation.

Sources

Related Articles in Terraform

Explore More DevOps Config Guides