Skip to main content

Terraform State at Scale: Backends & Locking

Terraform state is simple solo and a nightmare when five teams share it. Here's the guide to remote backends, locking, and drift management at scale.

Terraform state works perfectly when one engineer runs it locally on one project. It starts causing problems the moment a second engineer runs terraform apply at the same time. By the time you have five teams, multiple environments, and hundreds of resources, state management has become the most dangerous part of your infrastructure workflow.

This is the article that covers what actually goes wrong and how to design a state architecture that survives real multi-team usage.

The Core Architectural Challenge

Terraform uses the state file to map the resources it manages to the real infrastructure they correspond to. Without state, Terraform cannot know whether aws_instance.web in your config is an EC2 instance that already exists or one that needs to be created.

The state file is also the source of truth for your current infrastructure — it stores resource IDs, attribute values, and dependency relationships. It is, in a very real sense, more important than your Terraform code.

When state lives on a local filesystem, the problems are immediate. Two engineers apply at the same time and corrupt the state file. One engineer's laptop has the only copy of state for a production database. Nobody can audit what changed, when, and who ran it.

Remote backends solve the storage problem. State locking solves the concurrency problem. Drift detection solves the divergence problem. All three need to be in place before Terraform is used at team scale.

Setting Up Remote State with S3 and DynamoDB

AWS S3 with DynamoDB locking is the most common remote backend setup. S3 stores the state file, DynamoDB handles the lock.

HCL
## Mumbai region
terraform {
backend "s3" {
bucket = "platform-terraform-state"
key = "prod/payment-service/terraform.tfstate"
region = "ap-south-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
kms_key_id = "arn:aws:kms:ap-south-1:123456789:key/abc-123"
}
}
Bash
## One-time lock table setup
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region ap-south-1

Every terraform apply now acquires a lock in DynamoDB before modifying state, and releases it when done. Concurrent applies will see the lock and fail immediately with a clear error — no silent corruption.

Enable S3 versioning on the state bucket. This is not optional.

Bash
aws s3api put-bucket-versioning \
--bucket platform-terraform-state \
--versioning-configuration Status=Enabled

With versioning enabled, every apply creates a new version of the state file. When an apply corrupts state, you restore the previous version in thirty seconds.

How Do You Structure State for Multi-Team Orgs

The biggest mistake multi-team orgs make with Terraform state is using a single state file. When the payment team and the data platform team share one state file, the payment team's apply can destroy a data platform resource if someone wrote a bad resource block.

The solution is state separation by blast radius. Each independent unit of infrastructure gets its own state file:

Bash
platform-terraform-state/
prod/
networking/terraform.tfstate -- VPCs, subnets, NAT
payment-service/terraform.tfstate -- payment app infra
order-service/terraform.tfstate -- order app infra
data-platform/terraform.tfstate -- Kafka, S3, Redshift
monitoring/terraform.tfstate -- Prometheus, Grafana
staging/
...
dev/
...

This means a broken payment-service apply cannot affect data-platform resources. The blast radius of any failure is bounded by the state file boundary.

Use Terraform's remote_state data source to share outputs between state files without merging them.

HCL
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "platform-terraform-state"
key = "prod/networking/terraform.tfstate"
region = "ap-south-1"
}
}
resource "aws_instance" "payment_api" {
subnet_id = data.terraform_remote_state.networking.outputs.private_subnet_id
}

The networking team manages VPCs. The payment team consumes the subnet ID as a read-only reference. Neither team can accidentally modify the other's resources.

What Do State Locking Errors Actually Mean

When a lock is held and you try to apply, you see:

Bash
Error: Error acquiring the state lock
Lock Info:
ID: abc-123-def-456
Path: prod/payment-service/terraform.tfstate
Operation: OperationTypeApply
Who: jenkins@ci.internal.yourplatform.net
Version: 1.9.2
Created: 2026-08-20T10:22:31Z

This tells you exactly who holds the lock, when they acquired it, and from which machine. If it's a CI job that crashed without releasing the lock, you force-unlock with the ID.

Bash
terraform force-unlock abc-123-def-456

Never force-unlock while a legitimate apply is running. Check the CI job status first. Forcing an unlock during an active apply is how you corrupt state.

Detecting and Handling Drift

Drift is when the real infrastructure diverges from what Terraform's state file says it should be. Someone ran aws ec2 modify-instance-attribute directly. An auto-scaling event changed something. A manual fix was applied during an incident.

Bash
## Check for drift, no changes made
terraform plan -refresh-only
## Adopt a drifted resource into state
terraform import aws_instance.payment_api i-0abc123def456

For automated drift detection, run terraform plan -refresh-only in CI on a schedule.

YAML
name: Drift Detection
on:
schedule:
- cron: '0 8 * * 1-5' # 8 AM IST weekdays
jobs:
detect-drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/TerraformReadOnly
- name: Check for Drift
run: |
terraform init
## Exit code 2 means drift detected
terraform plan -refresh-only -detailed-exitcode

When this job exits with code 2, it means something changed outside of Terraform. The CI notification tells your team immediately instead of discovering it during the next apply.

Terraform Cloud and HCP Terraform

If managing S3 and DynamoDB backends across twenty modules is too much overhead, HCP Terraform handles backends, locking, and run history as a managed service.

The backend config simplifies to:

HCL
terraform {
cloud {
organization = "your-org"
workspaces {
name = "payment-service-prod"
}
}
}

Runs execute remotely, state is managed, and you get a full audit log of who applied what. The free tier supports up to 500 resources per month, which is enough for small teams to use it without cost.

Production Implementation Guidelines

Never run terraform apply directly from a developer's laptop in production. All production applies should go through a CI pipeline with approval gates. This creates an audit trail and prevents "works on my machine" state corruption.

Use separate AWS IAM roles for Terraform CI versus Terraform read-only. Drift detection doesn't need write permissions — give it a role that can only read state and describe resources. Reserve write permissions for the apply role, and require MFA or OIDC for that role.

Tag every Terraform-managed resource with a managed-by = terraform tag and the workspace or state path. When you find a resource in the console that looks unfamiliar, the tag tells you which state file owns it.

HCL
provider "aws" {
default_tags {
tags = {
ManagedBy = "terraform"
Environment = var.environment
StateFile = "prod/payment-service"
Team = "payments-squad"
}
}
}

Prefer moved blocks over terraform state mv for renames that touch production. A moved block is declared in code, reviewed in the pull request, and leaves an audit trail in git — an imperative state mv command is invisible to code review.

Trade-offs and Alternatives

Backend Locking Cost
Local None Free (dangerous)
S3 + DynamoDB Yes Very low
HCP Terraform Yes Free tier available
Terraform Enterprise Yes High

S3 + DynamoDB is the right default for AWS-native teams. HCP Terraform is better when you want managed runs, policy enforcement, and don't want to maintain the backend infrastructure.

Note

References and Further Reading

Frequently Asked Questions

What actually happens if two engineers run terraform apply on the same state at once without locking?

Without a lock, concurrent applies can corrupt the state file itself, leaving Terraform unable to reliably map its configuration to real infrastructure. With DynamoDB locking in place, the second apply fails immediately with a clear "lock held" error instead of silently corrupting anything.

Should you always force-unlock a stuck Terraform state lock?

No — only after confirming the lock isn't held by a legitimately running apply, such as an active CI job. Force-unlocking during a real in-progress apply is itself a common cause of state corruption, not a fix for it.

Why does splitting Terraform state by team or service actually matter?

Because it bounds blast radius — a broken apply in one team's state file (like payment-service) cannot affect resources in another team's state file (like data-platform), whereas a single shared state file means any team's mistake can touch every other team's infrastructure.

What's the practical difference between terraform state mv and a moved block for renaming resources?

A moved block is declared in code, goes through pull request review, and leaves a git audit trail. The imperative terraform state mv command runs directly against state and is invisible to code review — for anything touching production, the moved block is the safer, reviewable option.

Does drift detection need write access to your AWS account?

No — drift detection only needs to read state and describe resources, so it should use a read-only IAM role. Reserve write permissions, ideally behind MFA or OIDC, specifically for the apply role that actually makes changes.

Discussion0