Locking #

State locking is the mechanism that prevents two Terraform processes from running simultaneously on the same state. Without locking, two people running terraform apply at the same time can produce corrupted state — both read the old state, both make changes, and one overwrites the other’s work. This isn’t a rare edge case; in an active team, this race condition is very likely to happen.

flowchart LR
    A["Developer A\nterraform apply"] --> B["Read state\nserial: 5"]
    B --> C["Write state\nserial: 6"]
    D["Developer B\nterraform apply"] --> E["Read state\nserial: 5 (old!)"]
    E --> F["Write state\nserial: 6"]
    F --> G["State A\nOVERWRITTEN ❌"]

    style A fill:#e3f2fd,stroke:#1565c0
    style D fill:#e3f2fd,stroke:#1565c0
    style G fill:#ffebee,stroke:#c62828

Why Locking Is Crucial #

SCENARIO WITHOUT LOCKING:

Developer A (running terraform apply):
  t=0s: Read state (serial: 5)
  t=2s: Create aws_vpc.main → state serial: 6
  t=4s: Create aws_subnet.public → state serial: 7

Developer B (running terraform apply at the same time):
  t=1s: Read state (serial: 5) ← read the OLD state before A finished
  t=3s: Create aws_vpc.main → FAILS (already exists, created by A)
  t=5s: Write state (serial: 6) ← OVERWRITES A's state at serial: 7!

Result:
  - aws_vpc.main exists in the cloud, but B's state doesn't record it
  - aws_subnet.public exists in the cloud (created by A), but not in B's state
  - The state no longer reflects reality → permanent drift

How Locking Works #

When terraform apply or terraform plan starts, Terraform tries to acquire the lock before doing anything.

flowchart TD
    A["terraform apply starts"] --> B["Try to acquire the lock"]
    B --> C{"Lock acquired?"}
    C -->|"Yes"| D["Execute the plan"]
    C -->|"No"| E["Error: lock already held<br/>Show holder info"]
    D --> F["Release the lock"]
    F --> G["State saved"]

    style A stroke:#1565c0,stroke-width:2px
    style D stroke:#2e7d32,stroke-width:2px
    style E stroke:#c62828,stroke-width:2px
    style G stroke:#2e7d32,stroke-width:2px

Locking Across Different Backends #

# S3 Backend — needs DynamoDB for locking
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "ap-southeast-1"
    dynamodb_table = "terraform-state-lock"  # Required for locking
    encrypt        = true
  }
}

# Lock entry in DynamoDB while apply is running:
# LockID: "my-terraform-state/production/terraform.tfstate"
# Info:   {"ID":"abc123", "Operation":"OperationTypeApply",
#          "Who":"user@hostname", "Created":"2024-01-15T10:30:00Z"}
# GCS Backend — built-in locking, no extra resources needed
terraform {
  backend "gcs" {
    bucket = "my-terraform-state"
    prefix = "production"
    # Locking uses GCS object versioning — automatic
  }
}
# Terraform Cloud — built-in locking, managed by the platform
terraform {
  cloud {
    organization = "my-org"
    workspaces {
      name = "production"
    }
  }
}
flowchart TD
    A["Backend\nConfiguration"] --> B{"Backend Type?"}
    B -->|"S3"| C["DynamoDB Table\n(hash_key: LockID)"]
    B -->|"GCS"| D["GCS Object\nVersioning (built-in)"]
    B -->|"Terraform Cloud"| E["Platform Lock\n(built-in)"]

    C --> F["Lock entry\nin DynamoDB"]
    D --> G["Lock in\nGCS metadata"]
    E --> H["Lock in\nthe TFC API"]

    style A fill:#e3f2fd,stroke:#1565c0
    style C fill:#fff3e0,stroke:#e65100
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#e8f5e9,stroke:#2e7d32

Lock Error Messages and Their Meaning #

# The error that appears when a lock is held:
$ terraform apply

│ Error: Error acquiring the state lock
│ Error message: ConditionalCheckFailedException: ...
│ Lock Info:
│   ID:        abc123def456
│   Path:      my-terraform-state/production/terraform.tfstate
│   Operation: OperationTypeApply
│   Who:       [email protected]
│   Version:   1.6.3
│   Created:   2024-01-15 10:30:00.123456789 +0000 UTC
│   Info:

# Important information from this error:
# - ID: used for force-unlock if needed
# - Who: who currently holds the lock
# - Created: when the lock was taken (if it's old, it might be stuck)

Handling Stuck Locks #

Sometimes a lock isn’t released automatically — for example, if the Terraform process was forcibly cut off (Ctrl+C, a crash, a network timeout in CI/CD).

# Check whether there's an active lock
terraform plan
# If there's a lock, the error message will show the Lock ID

# Force unlock — use the Lock ID from the error message
terraform force-unlock abc123def456

# IMPORTANT: Make sure no other Terraform process is running
# before force-unlocking. Force unlocking an apply that's still
# running can corrupt state.
BEFORE FORCE UNLOCKING, VERIFY:

  □ No terraform process is running on another machine
  □ No CI/CD job is still active
  □ The lock is truly stuck (created time is old, e.g. > 30 minutes)
  □ You know who holds the lock and have confirmed with them

After force unlocking:
  □ Run terraform plan to verify the state is consistent
  □ Only continue with terraform apply if the plan is clean

Locking in CI/CD Pipelines #

In CI/CD pipelines, locks can become a problem if multiple pipelines run simultaneously. There are several strategies to manage this.

# Strategy 1: Serialized pipelines
# Make sure only one apply pipeline can run at a time
# (configuration in the CI/CD tool — GitHub Actions concurrency, GitLab resource groups)

# GitHub Actions: concurrency group
name: Terraform Apply
concurrency:
  group: terraform-production  # Only one workflow with this group may run
  cancel-in-progress: false    # Don't cancel the running one, queue it

jobs:
  apply:
    runs-on: ubuntu-latest
    steps:
      - name: Terraform Apply
        run: terraform apply -auto-approve tfplan
# Strategy 2: Reasonable timeouts
# If a pipeline is stuck and the lock isn't released after the timeout,
# the pipeline can be considered failed and an alert is sent

terraform apply -lock-timeout=5m  # Wait at most 5 minutes to acquire the lock
# If it doesn't succeed within 5 minutes, error and fail

Disabling Locking (Only for Emergencies) #

# ANTI-PATTERN: Permanently disabling locking
terraform apply -lock=false  # ✗ Very dangerous in a team environment

# CORRECT: Disable locking only when truly forced
# and you're sure no other process is running
terraform plan -lock=false   # E.g. only for emergency inspection

Distributed Locking Internals #

Each backend has a different locking implementation, but the basic principle is the same: an atomic compare-and-set operation that ensures only one process can hold the lock.

sequenceDiagram
    participant A as Process A
    participant Lock as Lock Service
    participant B as Process B
    
    A->>Lock: Acquire lock (ID: abc123)
    Lock-->>A: Lock acquired ✓
    B->>Lock: Acquire lock (ID: def456)
    Lock-->>B: Denied — lock held by abc123
    A->>Lock: Release lock (ID: abc123)
    Lock-->>A: Lock released ✓
    B->>Lock: Acquire lock (ID: def456)
    Lock-->>B: Lock acquired ✓
# S3 + DynamoDB locking internals:
# 1. Terraform creates an item in DynamoDB with a LockID
# 2. Uses a DynamoDB conditional write:
#    PutItem with ConditionExpression = "attribute_not_exists(LockID)"
# 3. If the LockID already exists → ConditionalCheckFailedException
# 4. Only one process writes successfully → atomic

# DynamoDB lock item structure:
{
  "LockID": "my-bucket/production/terraform.tfstate",
  "Info": {
    "ID": "a3b4c5d6-e7f8-9012-abcd-ef1234567890",
    "Operation": "OperationTypeApply",
    "Who": "[email protected]",
    "Version": "1.6.3",
    "Created": "2024-01-15T10:30:00Z",
    "Path": "my-bucket/production/terraform.tfstate"
  }
}

# GCS locking internals:
# 1. Terraform creates a .tflock file in the bucket
# 2. Uses GCS preconditions:
#    ifGenerationMatch: 0 (only succeeds if the file doesn't exist)
# 3. Deletes the .tflock file when releasing
# 4. GCS guarantees atomicity through preconditions

Lock Timeout Scenarios #

Terraform waits for a lock to be released for a configurable duration. Understanding the timeout behavior is very important for CI/CD pipelines.

# The default lock timeout is 0s — errors immediately if the lock is held
terraform apply
# Error: Error acquiring the state lock (fails immediately)

# Set a timeout to wait for the lock to be released
terraform apply -lock-timeout=5m   # Wait at most 5 minutes
terraform apply -lock-timeout=30s  # Wait at most 30 seconds
terraform apply -lock-timeout=1h   # Wait at most 1 hour
LOCK TIMEOUT BEHAVIOR:

  lock-timeout=0s (default):
    Try to acquire → fail → error immediately
    Best for: local developers who can retry manually

  lock-timeout=5m:
    Try to acquire → fail → retry every few seconds → 5 minutes → error
    Best for: CI/CD pipelines waiting for another apply to finish

  lock-timeout=30m:
    Wait longer for large applies with unpredictable timing
    Best for: applies managing hundreds of resources

  WARNING: If apply A runs for 45 minutes and B's lock-timeout is 30 minutes,
  pipeline B will time out and fail. Estimate the longest apply time
  to determine the right timeout.

Troubleshooting Stuck Locks #

# SITUATION: Lock is stuck, you don't know who holds it
$ terraform plan

Error: Error acquiring the state lock
Lock Info:
  ID:        a3b4c5d6-e7f8-9012-abcd-ef1234567890
  Operation: OperationTypeApply
  Who:       deploy-agent@ci-runner-03
  Created:   2024-01-15 10:30:00 +0000 UTC
  Info:

# STEP 1: Check whether the process is still running
# In CI/CD — check whether the runner/pod is still active
kubectl get pods -l app=terraform-runner
# If the pod no longer exists → the lock is stuck, safe to force-unlock

# STEP 2: Check the lock's age
# A lock older than 30 minutes without progress is usually stuck
LOCK_CREATED="2024-01-15T10:30:00Z"
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "Lock age: $(($(date -d "$NOW" +%s) - $(date -d "$LOCK_CREATED" +%s))) seconds"

# STEP 3: Check DynamoDB directly (for the S3 backend)
aws dynamodb get-item \
  --table-name terraform-state-lock \
  --key '{"LockID":{"S":"my-bucket/production/terraform.tfstate"}}'

# STEP 4: Force unlock if you're sure no process is active
terraform force-unlock a3b4c5d6-e7f8-9012-abcd-ef1234567890

# STEP 5: Verify the state is consistent after unlocking
terraform plan
# Should be "No changes" — if there are unexpected changes,
# the previous apply was probably cut off mid-way

Lock Table Design Patterns #

For large organizations managing many states, the DynamoDB lock table needs good design.

# A lock table usable for many states (shared)
resource "aws_dynamodb_table" "terraform_lock" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"  # No provisioned capacity needed
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"  # String
  }

  # TTL for auto-cleaning stuck locks
  # (needs a TTL attribute named "ExpiresAt" on items)
  ttl {
    attribute_name = "ExpiresAt"
    enabled        = true
  }

  point_in_time_recovery {
    enabled = true  # Back up the lock table
  }
}
# PATTERNS:

# 1. Shared lock table for all states
#    One DynamoDB table, many different LockIDs
#    Best for: small-medium organizations (< 50 state files)
#    LockID: "bucket-name/path/to/terraform.tfstate"

# 2. Per-environment lock tables
#    Separate DynamoDB table per environment
#    terraform-lock-production, terraform-lock-staging
#    Best for: strict isolation between environments
#    Separates IAM permissions per table

# 3. Per-team lock tables
#    Each team has its own lock table
#    Infrastructure team: terraform-lock-infra
#    Application team: terraform-lock-apps
#    Best for: large organizations with many teams

# RECOMMENDATION: Start with pattern 1 (shared), split
# only if there's a specific IAM isolation need

Summary #

  • Locking prevents corrupted state from two apply processes running simultaneously — this isn’t an optional feature but a requirement in team environments.
  • S3 needs DynamoDB for locking — the dynamodb_table configuration in the S3 backend is mandatory, not optional.
  • GCS and Terraform Cloud have built-in locking with no extra configuration.
  • Lock errors contain a Lock ID — keep it in case you need to force-unlock, but verify no active process first.
  • Force unlock only when the lock is truly stuck — force unlocking an apply that’s still running can corrupt state.
  • Serialized CI/CD pipelines are the best way to prevent lock conflicts — use concurrency groups or resource locks in your CI/CD tool.
  • Distributed locking uses atomic compare-and-set — S3+DynamoDB uses conditional writes, GCS uses generation preconditions.
  • The default lock timeout is 0 seconds (errors immediately) — raise it to 5 minutes for CI/CD that needs to wait for another apply to finish.
  • Troubleshooting stuck locks: check whether the process is still running, calculate the lock’s age, check DynamoDB directly, then force-unlock only if you’re sure it’s safe.
  • Lock table design: start with a shared table for all states, split only if there’s an IAM isolation need.

← Previous: Remote   Next: Migration →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact