Terraform State Management in AWS: Remote State, S3 Backend, Locking & Recovery

Terraform state is the record of everything Terraform has created in your AWS account. Get state management wrong and your team will eventually race-condition their way into corrupted infrastructure or accidentally destroy a production database. This page explains what state is, why local state breaks in team environments, and how to configure a safe S3 backend with native locking, versioning, and the IAM setup your security team will actually accept.

Simple explanation

Imagine you hire a contractor to renovate rooms in your house. They keep a notebook listing every room they built, every wall they knocked down, every fixture they installed. When they come back next week, they open the notebook before touching anything. Without it, they would have no idea what they already built and would try to rebuild the same rooms from scratch.

Terraform’s state file is that notebook. When you run terraform apply, Terraform creates the AWS resources you described and writes down what it created: resource IDs, ARNs, attributes, relationships. The next time you run terraform plan, Terraform reads that record, checks whether reality still matches it, and compares both against your current configuration to work out what needs to change.

By default the notebook lives in your current directory as a file called terraform.tfstate. That is fine for a solo lab experiment lasting a few hours. In any team or CI/CD environment, it breaks immediately. The rest of this page explains how to fix that.

If you are new to Terraform itself, start with the Terraform for AWS guide and come back here once you understand the basics.

Why Terraform state matters

State is what makes Terraform declarative. You describe the desired end state, Terraform figures out the delta. That delta calculation depends entirely on accurate state.

Three things go wrong without proper state management:

Drift goes undetected. If someone changes a resource manually in the AWS console, Terraform can only detect that drift by comparing the real resource against the state file. No state means no drift detection.

Destroys become unpredictable. Terraform uses state to know what to delete during terraform destroy. Corrupt state can cause Terraform to attempt to delete resources it no longer tracks, or miss ones it should remove.

Sensitive values leak. The state file can contain database passwords, private key material, and other secrets written by providers. If state is local, it ends up in git history or on engineers’ laptops.

// Simplified excerpt from terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.10.0",
  "resources": [
    {
      "type": "aws_s3_bucket",
      "name": "example",
      "instances": [
        {
          "attributes": {
            "id": "my-unique-bucket-name-2026",
            "arn": "arn:aws:s3:::my-unique-bucket-name-2026",
            "region": "us-east-1"
          }
        }
      ]
    }
  ]
}

Never commit terraform.tfstate to git Add *.tfstate and *.tfstate.backup to your .gitignore immediately. State files frequently contain sensitive values, and their JSON format produces noisy diffs that serve no useful purpose in a pull request.

Local state vs remote state

Local stateRemote state (S3)
Locationterraform.tfstate on diskS3 bucket, shared by all callers
Shared accessNo (each person has their own copy)Yes
LockingNoYes (S3 native or DynamoDB legacy)
Versioning/recoveryNoYes (S3 versioning)
EncryptionNoYes (SSE-S3 or KMS)
Good forSolo throwaway labsTeams, CI/CD, production

Local state is only acceptable for completely throwaway experiments in a scratch account. If infrastructure is shared, lives in CI/CD, or persists longer than a day, use remote state.

If you prefer a fully managed option, HCP Terraform (formerly Terraform Cloud) handles state storage, locking, and access control without requiring you to manage S3 and IAM yourself. This page focuses on the self-managed S3 approach, which is what most AWS teams use.

When to use remote state

Use remote state whenever any of these are true:

  • More than one person runs Terraform on the same infrastructure
  • Terraform runs from CI/CD (CodeBuild, GitHub Actions, or similar)
  • The infrastructure is production, shared staging, or long-lived
  • You need an audit trail of what changed and when

Quick decision rule If you will terraform destroy everything before closing your laptop today, local state is fine. If anyone else might touch this infrastructure, or it will exist tomorrow, use remote state.

How Terraform state works

Think of it like a bank reconciliation. Terraform checks its records (the state file), verifies current reality against the bank (the AWS API), then figures out what transactions are needed to reach the target balance (your configuration).

The sequence for every terraform plan or terraform apply:

  1. Read the current state file from the configured backend (S3 in this case).
  2. Query the AWS provider to refresh the real-world attributes of every tracked resource. This is the refresh phase. It catches manual changes made outside Terraform.
  3. Compute a diff between the refreshed reality and your .tf configuration.
  4. Acquire a lock before writing anything. This applies to apply, destroy, and -refresh-only operations. No other caller can apply concurrently.
  5. Write the updated state snapshot back to S3 and release the lock after a successful apply.

If the process dies partway through step 5 (network cut, kill signal, power loss), the lock may remain. That is what terraform force-unlock handles. Use it only after confirming no operation is actually still running.

For most teams, the right setup is:

  • One S3 bucket per project (or per AWS account) dedicated to Terraform state
  • S3 native locking (use_lockfile = true) with no extra DynamoDB table required (Terraform 1.10+)
  • S3 versioning enabled for automatic backup of every state write
  • Server-side encryption (SSE-S3 minimum; KMS for production)
  • Public access blocked completely
  • Separate S3 key per environment so dev, staging, and prod never share a state file

See Terraform project structure for AWS teams for how to organize environment directories so each gets its own backend key automatically.

How to configure remote state in AWS

Bootstrap the backend resources

The S3 bucket must exist before Terraform can use it as a backend. You cannot use Terraform to create its own backend (the classic chicken-and-egg problem). The standard approach is to create the bucket once via the AWS CLI, or with a minimal separate “bootstrap” Terraform config that uses local state.

# Create the bucket
aws s3api create-bucket \
  --bucket my-project-terraform-state \
  --region us-east-1

# Enable versioning — critical for state recovery
aws s3api put-bucket-versioning \
  --bucket my-project-terraform-state \
  --versioning-configuration Status=Enabled

# Enable server-side encryption
aws s3api put-bucket-encryption \
  --bucket my-project-terraform-state \
  --server-side-encryption-configuration '{
    "Rules": [{
      "ApplyServerSideEncryptionByDefault": {
        "SSEAlgorithm": "AES256"
      }
    }]
  }'

# Block all public access
aws s3api put-public-access-block \
  --bucket my-project-terraform-state \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

Enable versioning before you store any state S3 versioning is your primary recovery mechanism if state is corrupted or accidentally overwritten. Enable it when you create the bucket. If you add it later, you only get version history from that point forward.

Backend configuration (Terraform 1.10+, recommended)

# backend.tf
terraform {
  required_version = ">= 1.10"

  backend "s3" {
    bucket       = "my-project-terraform-state"
    key          = "environments/dev/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true   # S3 native locking, no DynamoDB table needed
  }
}

The key is the path inside the S3 bucket where the state file is stored. Use a unique key per environment so each has fully isolated state:

environments/dev/terraform.tfstate
environments/staging/terraform.tfstate
environments/prod/terraform.tfstate

Migrate existing local state

If you already have a local terraform.tfstate and want to move it to S3, no resources need to be recreated. Only the location of the state file changes.

# 1. Add the backend block to your configuration, then run:
terraform init

# Terraform detects local state and prompts:
# "Do you want to copy existing state to the new backend?"
# Type: yes

# 2. Verify the migration succeeded
terraform state list

# 3. Remove the now-redundant local state files
rm terraform.tfstate terraform.tfstate.backup

Verify before you delete Run terraform state list after init and confirm the resource count matches what you expect before deleting the local files.

Locking explained

Think of the lock as a “do not disturb” sign on the state file. When Terraform starts applying changes, it hangs the sign. Any other Terraform process that tries to start sees the sign and waits. When the apply finishes, the sign comes down and the next process can proceed.

Without the sign, two engineers could apply at the same time, read the same state, calculate changes independently, and both write conflicting results. The state file ends up describing a mix of two different intended worlds.

S3 native locking (recommended)

Introduced in Terraform 1.10, S3 native locking works by writing a .tflock file directly in S3 alongside the state file. When another terraform apply runs and finds that lock file, it waits until the lock is released. No DynamoDB table needed, and no extra IAM permissions beyond the S3 bucket itself.

Enable it by adding use_lockfile = true to the backend “s3” block. That is the only change required for new setups on Terraform 1.10 or later.

DynamoDB locking (legacy, Terraform older than 1.10)

The original locking mechanism writes a lock record to a DynamoDB table. It still works, but requires creating and maintaining a separate DynamoDB table and additional IAM permissions. For any new Terraform 1.10+ setup, there is no reason to choose DynamoDB locking.

If you have existing infrastructure already using DynamoDB locking, it continues to work. Migrate away from it when you upgrade to Terraform 1.10 or later.

# Legacy backend — use only for Terraform older than 1.10 or when migrating existing setups
terraform {
  backend "s3" {
    bucket         = "my-project-terraform-state"
    key            = "environments/dev/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "my-project-terraform-locks"
    encrypt        = true
  }
}
# Creating the DynamoDB lock table (legacy / migration-only)
aws dynamodb create-table \
  --table-name my-project-terraform-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST \
  --region us-east-1

S3 native locking vs DynamoDB locking

S3 native (use_lockfile = true)DynamoDB (legacy)
Minimum Terraform version1.10Any
Extra AWS resource neededNoYes (DynamoDB table)
Extra IAM permissionsNoYes (dynamodb:GetItem, etc.)
Recommended for new setupsYesNo
Still functionalYesYes

Releasing a stuck lock

If Terraform exits unexpectedly (power loss, killed process), the lock may not be released automatically.

# Force-release a stuck lock (only when no operation is actually running)
terraform force-unlock LOCK_ID

# The lock ID appears in the error when a lock is held:
# Error: Error locking state: ...
# Lock Info:
#   ID:        abc123
#   Operation: OperationTypeApply
#   Who:       alice@laptop

Confirm before you force-unlock terraform force-unlock removes the lock regardless of whether an operation is still running. If a colleague is mid-apply and you force-unlock, their write protection disappears. Always confirm with the lock owner first.

Security, IAM, and auditing

Keep the state bucket private

The state bucket must have public access blocked entirely. State files contain resource IDs, ARNs, and often sensitive values written by providers: database passwords, private key material, connection strings. Exposing the bucket publicly hands an attacker a complete map of your infrastructure before they touch a single service. Learn more about bucket access controls in the Amazon S3 overview.

Least-privilege IAM for the state backend

The IAM principal that runs Terraform (a CI/CD role, or a developer’s assumed role) should have the minimum permissions needed on the state bucket. For the S3 native locking backend on Terraform 1.10+:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::my-project-terraform-state",
        "arn:aws:s3:::my-project-terraform-state/*"
      ]
    }
  ]
}

DeleteObject is required for native locking. Terraform deletes the .tflock file when the operation finishes. See managing IAM with Terraform for how to define these policies in code rather than clicking through the console. If you hit access denied errors on the state backend, the Terraform permission errors troubleshooting guide covers the most common causes.

Use short-lived credentials in CI/CD For CI/CD pipelines, use STS temporary credentials rather than long-lived access keys. A compromised long-lived key with state bucket access is a serious incident. See secure CI/CD pipelines for the full OIDC-based credential pattern for CodeBuild and GitHub Actions.

KMS encryption for production state

The encrypt = true setting uses SSE-S3 (AWS-managed keys). For stricter environments where you need key rotation control, key-level access restrictions, or cryptographic auditability, use a KMS customer-managed key:

terraform {
  backend "s3" {
    bucket       = "my-project-terraform-state"
    key          = "environments/prod/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
    kms_key_id   = "arn:aws:kms:us-east-1:123456789012:key/mrk-abc123"
  }
}

With KMS, reading the state file requires both S3 bucket permissions and KMS key permissions. Those are two separate controls an attacker would need to compromise independently.

Audit state access with CloudTrail

AWS CloudTrail logs every S3 GetObject, PutObject, and DeleteObject call on the state bucket. Enabling a CloudTrail data event trail gives you a complete audit log: who read state, who wrote state, and when. This is valuable for incident investigation and required for several compliance frameworks.

Environment isolation and team workflows

Think of separate state files the way you would think of separate notebooks for separate job sites. A contractor who mixes renovation notes from two different houses into one notebook and crosses something out could demolish the wrong wall. Keeping one notebook per job site means a mistake on one site stays contained.

The same logic applies to Terraform environments. A Terraform bug in dev that accidentally deletes a resource will only affect dev if dev and prod have separate state files. If they share one file, you may affect production.

Use separate S3 keys at minimum:

environments/dev/terraform.tfstate
environments/staging/terraform.tfstate
environments/prod/terraform.tfstate

Never share one state file across environments This is one of the most common and most costly mistakes teams make. Shared state means a terraform plan in dev can show changes to prod resources. Separate state files from the start; it is painful to split them later.

For stronger isolation guarantees, use separate S3 buckets per environment, or separate AWS accounts. Separate accounts are the most airtight option: even an IAM misconfiguration in the dev account cannot reach prod state.

The managing environments in CI/CD guide covers how to wire up isolated backends across environments in a real pipeline, including how to pass different backend keys to the same Terraform module per environment.

CI/CD as the single writer When multiple engineers work against the same environment, locking prevents race conditions but does not prevent logical conflicts. A practical convention: treat each environment as owned by CI/CD. Engineers do not apply directly to staging or production from their laptops. Changes go through the pipeline. That gives you a single writer, a clear audit trail from pipeline logs, and no need to coordinate who holds the lock.

Versioning and recovery

S3 versioning keeps every version of the state file. When terraform apply writes a new state, the previous version is preserved automatically. This is your primary safety net when something goes wrong.

# List all versions of a state file
aws s3api list-object-versions \
  --bucket my-project-terraform-state \
  --prefix environments/prod/terraform.tfstate

# Download a specific previous version
aws s3api get-object \
  --bucket my-project-terraform-state \
  --key environments/prod/terraform.tfstate \
  --version-id <version-id> \
  terraform.tfstate.restored

# Upload the restored version as the current state
aws s3 cp terraform.tfstate.restored \
  s3://my-project-terraform-state/environments/prod/terraform.tfstate

After restoring, run terraform plan before running any apply. Verify the restored state matches what is actually running in AWS. If there are discrepancies, use terraform import to re-associate resources Terraform does not recognize.

Useful terraform state commands

Use these carefully. Most should be used rarely and with explicit intent. They modify state directly without making any API calls to AWS.

# List all resources Terraform is currently tracking
terraform state list

# Show all attributes of a specific resource
terraform state show aws_s3_bucket.example

# Rename a resource in state after renaming it in .tf files
# Without this step, Terraform destroys the old resource and creates a new one
terraform state mv aws_s3_bucket.old_name aws_s3_bucket.new_name

# Stop Terraform from tracking a resource (does NOT destroy it in AWS)
terraform state rm aws_s3_bucket.example

# Import an existing AWS resource into state
terraform import aws_s3_bucket.example my-existing-bucket-name

# Release a stuck lock (only when no operation is actually running)
terraform force-unlock LOCK_ID

Rename resources with state mv, not just in the .tf file If you rename a resource block in your .tf files without running terraform state mv first, Terraform will plan to destroy the old resource and create a new one. On a production database, that means real downtime.

Common mistakes

  1. Committing terraform.tfstate to git. State files contain sensitive values. Add *.tfstate and *.tfstate.backup to .gitignore immediately. If they have already been committed, rotate any credentials visible in the state and scrub the git history.
  2. Not enabling S3 versioning before storing state. Without versioning, a corrupted or accidentally overwritten state file is unrecoverable. Enable versioning when you create the bucket, before using it for anything.
  3. Sharing one state file across environments. Dev, staging, and prod must have separate state. Shared state means a Terraform bug in dev can accidentally plan destructive changes to prod resources.
  4. Using remote state without locking. Omitting use_lockfile = true (or the legacy dynamodb_table) means two concurrent applies race to write state. The result is usually a corrupted state file or partially applied changes.
  5. Over-permissive backend IAM. The Terraform role should only have access to the specific S3 path it needs, not full S3 access or full account permissions. Scoped permissions limit the blast radius if a credential is compromised.
  6. Forgetting versioning on the state bucket. Easy to overlook during bootstrap. Make it part of the bucket creation step, not an afterthought following your first incident.
  7. Force-unlocking carelessly. terraform force-unlock removes the lock regardless of whether an operation is still running. If a colleague is mid-apply and you force-unlock, their write protection disappears. Always confirm with the lock owner first.
  8. Manually editing the state file. The state file is JSON and can technically be edited in a text editor. Do not do this. Manual edits frequently produce corruption. Use terraform state mv, terraform state rm, and terraform import instead.

Frequently asked questions

What is Terraform state in plain English?

Terraform state is a file that records what Terraform has already created in your AWS account. When you run terraform plan, Terraform reads that file to know what exists, then compares it to your configuration to figure out what needs to change. Without it, Terraform cannot tell the difference between "create this" and "this already exists".

Should I use S3 native locking or DynamoDB?

Use S3 native locking (use_lockfile = true) for any new setup running Terraform 1.10 or later. No extra AWS resources required. DynamoDB locking is the legacy approach and requires a separate DynamoDB table with additional IAM permissions. Migrate away from DynamoDB locking when you upgrade to Terraform 1.10+.

Can I migrate local state to S3 without recreating resources?

Yes. Add the backend block to your configuration, then run terraform init. Terraform detects your existing local state and asks if you want to copy it to the new backend. Confirm, and the state file is uploaded to S3. No resources are destroyed or recreated. Only where the state file lives changes.

What happens if the state file is corrupted?

If S3 versioning is enabled, restore a previous version from the S3 console or CLI, then run terraform plan to verify the restored state matches reality. If state is deleted entirely, re-associate existing resources using terraform import. Both recovery paths are painful, which is why versioning and locking matter so much.

Should dev and prod share the same backend?

No. Use separate S3 keys at minimum: environments/dev/terraform.tfstate and environments/prod/terraform.tfstate. For stronger isolation, use separate buckets or separate AWS accounts. Shared state means a Terraform mistake in dev can accidentally plan changes to prod resources.

Last verified: 25 April 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.