← Back to Blog RSS

Store Terraform State in S3 (with DynamoDB Locking)

Josh Pollara October 10th, 2025
UPDATE
Terraform 1.10+
DynamoDB is no longer required for state locking. Terraform 1.10+ supports native S3 locking via use_lockfile = true. This guide covers the DynamoDB approach as it remains widely deployed and fully supported.
TL;DR
$ cat s3-backend.tldr
• S3 bucket stores state with versioning and encryption
• DynamoDB table coordinates locks to prevent concurrent writes
• IAM policies grant minimal permissions, backend config points to both
• terraform init migrates local state to remote backend

Local Terraform state files work fine until you add teammates, CI/CD pipelines, or accidentally delete your laptop. Then you're rebuilding infrastructure from memory while explaining to your CTO why the entire prod environment thinks it doesn't exist.

Remote state on S3 with DynamoDB locking solves the collaboration problem. Your state lives in a durable, versioned, encrypted bucket that the whole team shares. DynamoDB prevents concurrent writes that corrupt state. It's the difference between infrastructure that survives team scale and infrastructure that breaks when two people run terraform apply at the same time.

This guide walks through the complete S3 backend setup: bucket configuration with encryption and versioning, DynamoDB table for locking, IAM policies that follow least-privilege principles, backend configuration, and security practices for production environments. Every code block is copy/paste ready.

Why S3 for Terraform State

Terraform supports multiple backend options, but S3 is the natural choice for AWS-based teams. S3 provides 99.999999999% durability (11 nines), meaning the probability of losing your state file due to storage failure is effectively zero. It's fully managed, globally available, and integrates natively with AWS security services.

S3 offers features purpose-built for state management:

Versioning keeps every state file update as a separate object version. If someone breaks state with a bad apply, you can restore the previous version. No data loss from overwrites.

Server-side encryption protects state files at rest. Terraform state contains resource IDs, configuration details, and sometimes secrets. Encryption isn't optional.

Access control via IAM lets you restrict who can read or write state. Terraform processes get exactly the permissions they need. Everyone else gets denied.

CloudTrail integration logs every access to the state bucket. You can audit who read state, when they wrote it, and trace infrastructure changes back to specific users or CI jobs.

S3 State Storage

Terraform stores state as an object at s3://bucket-name/path/terraform.tfstate. Every apply reads the latest state, makes changes, then writes an updated state back. With versioning enabled, previous states are preserved automatically.

For AWS workloads, S3 is the preferred backend. It's already in your account, costs pennies per month for state storage, and handles all the durability and availability concerns that make local state unreliable.

State Locking with DynamoDB

State files are mutable. If two terraform apply runs execute concurrently without coordination, both read the same starting state, make different changes, then write back their versions. The second write wins, silently discarding the first. Resources get lost. State becomes corrupted. Recovery is painful.

State locking prevents this by allowing only one Terraform process to modify state at a time. Before any write operation, Terraform acquires a lock. Other processes attempting to acquire the lock wait or fail. After completion, Terraform releases the lock.

DynamoDB provides the distributed lock mechanism for S3 backends. It's a NoSQL database with conditional writes, which Terraform uses to implement a lock table. The setup is simple: create a table with a primary key named LockID, and Terraform handles the rest.

How Locking Works

Terraform writes an item to the DynamoDB table with a conditional write that only succeeds if the item doesn't exist. This atomic operation guarantees only one process holds the lock. When done, Terraform deletes the item to release the lock.

The DynamoDB table stores a single item per concurrent operation (usually just one item most of the time). Costs are negligible with on-demand billing. The lock item contains metadata about who holds the lock and when it was acquired, making stuck locks easy to debug.

Note on S3 native locking: Terraform 1.10+ supports native S3 locking via use_lockfile = true, which creates a .tflock object instead of using DynamoDB. This guide covers DynamoDB locking as it remains the proven, widely-deployed method. Both work fine; DynamoDB has broader adoption in existing infrastructure.

Step 1: Create the S3 Bucket

First, create an S3 bucket to store state files. Choose a unique name following your organization's naming convention. The bucket should live in the region where your Terraform runs execute.

resource "aws_s3_bucket" "terraform_state" {
bucket = "mycompany-terraform-state"
}
# Enable versioning to keep state history
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# Enable server-side encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Block all public access
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

This configuration creates a bucket with three critical features:

Versioning preserves every state file update. Each terraform apply creates a new object version instead of overwriting. If you need to roll back, retrieve the previous version from S3's version history.

Encryption at rest via SSE-S3 (AES-256). All state files stored in the bucket are encrypted automatically. For additional control, use SSE-KMS with a customer-managed key instead of AES256.

Public access block ensures the bucket and its objects are never publicly accessible, regardless of bucket policies or ACLs. This prevents accidental exposure of state files.

Optional: Enforce HTTPS-Only Access

Add a bucket policy that denies any non-HTTPS requests, ensuring encryption in transit:

resource "aws_s3_bucket_policy" "terraform_state_https" {
bucket = aws_s3_bucket.terraform_state.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.terraform_state.arn,
"${aws_s3_bucket.terraform_state.arn}/*"
]
Condition = {
Bool = {
"aws:SecureTransport" = "false"
}
}
}
]
})
}

Step 2: Create the DynamoDB Table

Create a DynamoDB table with a single attribute: LockID as the partition key. Terraform uses this to coordinate state locks across concurrent operations.

resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-state-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}

Key configuration details:

Table name: terraform-state-locks (can be anything, but make it descriptive).

Billing mode: PAY_PER_REQUEST is ideal for state locking. Lock writes happen infrequently (only during applies), so you pay pennies per month instead of provisioning fixed read/write capacity.

Primary key: Must be named LockID with type String. Terraform expects exactly this attribute name. No sort key or secondary indexes needed.

That's it. DynamoDB will store one item when a lock is held, zero items when no operations are running. Terraform handles all the conditional write logic to acquire and release locks.

Step 3: Configure IAM Permissions

The IAM role or user running Terraform needs specific permissions to the S3 bucket and DynamoDB table. Follow least-privilege principles: grant only the exact actions required, scoped to the specific resources.

IAM Policy for S3 Backend

resource "aws_iam_policy" "terraform_state_access" {
name = "TerraformStateAccess"
description = "Permissions for Terraform to manage state in S3 and locks in DynamoDB"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "S3ListBucket"
Effect = "Allow"
Action = [
"s3:ListBucket"
]
Resource = "arn:aws:s3:::mycompany-terraform-state"
},
{
Sid = "S3StateAccess"
Effect = "Allow"
Action = [
"s3:GetObject",
"s3:PutObject"
]
Resource = "arn:aws:s3:::mycompany-terraform-state/*/terraform.tfstate"
},
{
Sid = "DynamoDBLocking"
Effect = "Allow"
Action = [
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
]
Resource = "arn:aws:dynamodb:us-east-1:ACCOUNT_ID:table/terraform-state-locks"
}
]
})
}

Replace ACCOUNT_ID with your AWS account ID and us-east-1 with your region.

S3 permissions breakdown:

s3:ListBucket on the bucket resource lets Terraform check if state files exist.

s3:GetObject and s3:PutObject on state file objects allow reading and writing state. The resource ARN restricts access to terraform.tfstate files only, not the entire bucket.

DynamoDB permissions breakdown:

dynamodb:DescribeTable lets Terraform verify the table exists.

dynamodb:GetItem checks if a lock exists.

dynamodb:PutItem acquires a lock (conditional write).

dynamodb:DeleteItem releases a lock.

Attach this policy to the IAM role your CI/CD pipeline assumes, or to the IAM user developers use for Terraform operations. Scope permissions as narrowly as possible.

Step 4: Configure the Terraform Backend

With infrastructure in place, configure Terraform to use the S3 backend. Add a backend block to your Terraform configuration (typically in a file named backend.tf or within your main configuration).

terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}

Configuration parameters:

bucket — The S3 bucket name created in Step 1.

key — The path and filename for the state file within the bucket. Use descriptive paths like environment/component/terraform.tfstate to organize multiple states.

region — AWS region where the bucket exists.

dynamodb_table — The DynamoDB table name created in Step 2.

encrypt = true — Enables SSE-S3 encryption for state files uploaded to S3.

Using KMS for Encryption

For customer-managed encryption keys, specify the KMS key ARN:

terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
kms_key_id = "arn:aws:kms:us-east-1:ACCOUNT_ID:key/KEY_ID"
}
}

The IAM role running Terraform needs kms:Encrypt, kms:Decrypt, and kms:GenerateDataKey permissions on the specified KMS key.

Migrate Local State to S3

After adding the backend configuration, initialize Terraform to migrate existing local state to S3:

$ terraform init -migrate-state
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "s3" backend. No existing state was found in the newly
configured "s3" backend. Do you want to copy this state to the new "s3"
backend? Enter "yes" to copy and "no" to start with an empty state.
> yes
Successfully configured the backend "s3"!
Terraform will now use the S3 backend.

Terraform uploads your local terraform.tfstate to S3 at the specified key. After migration, delete your local state files—they're no longer the source of truth. All future operations use the remote backend.

Step 5: Verify Locking Works

Test that state locking prevents concurrent modifications. Open two terminals and run terraform apply simultaneously in both:

$ terraform apply
Acquiring state lock. This may take a few moments...
Lock acquired!
$ terraform apply
Acquiring state lock. This may take a few moments...
Error: Error acquiring the state lock
Lock Info:
ID: 8a1d2f3e-4b5c-6d7e-8f9a-0b1c2d3e4f5a
Path: mycompany-terraform-state/prod/network/terraform.tfstate
Operation: OperationTypeApply
Who: user@hostname
Created: 2025-10-10 15:30:42.123456789 +0000 UTC

The second apply blocks until the first releases the lock. This serialized execution prevents state corruption from concurrent writes.

If a lock gets stuck (process crashed, CI job killed), force-unlock using the lock ID:

$ terraform force-unlock 8a1d2f3e-4b5c-6d7e-8f9a-0b1c2d3e4f5a
Do you really want to force-unlock?
Terraform will remove the lock on the remote state.
> yes
Terraform state has been successfully unlocked!

Only use force-unlock after confirming no other Terraform process is actually running. The lock ID acts as a safety check to prevent unlocking the wrong lock.

Security Best Practices

S3 backends store sensitive infrastructure data. Securing the state bucket and DynamoDB table is not optional for production environments.

Enable CloudTrail Logging

CloudTrail logs all API calls to S3 and DynamoDB. Enable data events for your state bucket to audit who accessed state files:

resource "aws_cloudtrail" "state_audit" {
name = "terraform-state-audit"
s3_bucket_name = aws_s3_bucket.cloudtrail_logs.id
include_global_service_events = false
event_selector {
read_write_type = "All"
include_management_events = true
data_resource {
type = "AWS::S3::Object"
values = ["${aws_s3_bucket.terraform_state.arn}/"]
}
}
}

CloudTrail records GetObject and PutObject calls on the state bucket. Query logs to determine who read or modified state, when they did it, and from what source IP. This audit trail is invaluable for compliance and incident investigation.

Separate State per Environment

Use different S3 keys (or separate buckets) for dev, staging, and production environments. This isolation prevents a mistake in one environment from affecting another.

Environment Isolation

Production state should have stricter access controls than dev state. Different IAM roles per environment, different DynamoDB tables if needed, and definitely different S3 keys ensure changes in dev can't touch prod infrastructure.

Example key structure:

mycompany-terraform-state/
├── dev/
│ ├── network/terraform.tfstate
│ └── app/terraform.tfstate
├── staging/
│ ├── network/terraform.tfstate
│ └── app/terraform.tfstate
└── prod/
├── network/terraform.tfstate
└── app/terraform.tfstate

Never Commit Credentials to Code

Backend configuration should not contain AWS access keys. Use IAM roles in CI/CD, AWS CLI profiles for local development, or environment variables. Terraform authenticates to S3 and DynamoDB using the ambient AWS credentials.

For CI/CD pipelines, configure the job to assume an IAM role with permissions to the state bucket and lock table. Supply the role ARN via environment variables or pipeline configuration, not in Terraform code.

Restrict Access to State Bucket

Only Terraform processes should have access. Developers don't need direct S3 access to the state bucket—all operations go through Terraform CLI, which handles state reads and writes.

Lock down the S3 bucket and DynamoDB table to specific IAM principals. Use SCPs or permission boundaries if needed to prevent privilege escalation.

Common Issues and Solutions

Error: Failed to Get State Lock

This occurs when another Terraform process holds the lock. Wait for the other operation to complete, or if it's stuck (crashed process), use terraform force-unlock with the lock ID from the error message.

Error: AccessDenied on S3 Bucket

The IAM role/user running Terraform lacks required S3 permissions. Verify the policy includes s3:ListBucket on the bucket and s3:GetObject/s3:PutObject on the state object path.

Error: ResourceNotFoundException on DynamoDB Table

The DynamoDB table specified in the backend config doesn't exist, or it exists in a different region. Verify the table name and region match your backend configuration.

State File Corrupted

If state becomes corrupted, retrieve a previous version from S3's version history. Navigate to the bucket in the AWS Console, select the state object, and restore a prior version. Versioning must be enabled for this to work (which is why Step 1 enabled it).

Wrong Region Configuration

Terraform can't find the S3 bucket because the region in the backend config doesn't match where the bucket exists. S3 bucket names are globally unique, but buckets themselves live in specific regions. Ensure region in your backend block matches the bucket's region.

The Bottom Line

Local state files don't survive team collaboration, CI/CD pipelines, or laptop failures. S3 with DynamoDB locking provides the durable, versioned, encrypted remote state backend that production Terraform requires.

The setup is straightforward: create an S3 bucket with versioning and encryption, create a DynamoDB table with LockID as the primary key, configure IAM policies following least privilege, and add a backend block to your Terraform configuration. After terraform init -migrate-state, your local state moves to S3 and all future operations use the remote backend.

Enable CloudTrail logging for audit trails. Separate state by environment using different S3 keys. Never commit AWS credentials to code. Restrict bucket access to Terraform processes only.

State is Infrastructure

Your state file is as critical as your production database. Treat it accordingly: encryption at rest and in transit, access controls, versioning for recovery, and audit logging for compliance. S3 and DynamoDB provide all of this out of the box.

Teams that configure remote state properly gain reliable collaboration, protection against concurrent modifications, and recovery options when things go wrong. Teams that skip it deal with corrupted state, lost infrastructure mappings, and 2 AM recovery efforts.

Beyond file-based state backends

Stategraph replaces file-based state with a graph database and resource-level locking.
Your team works in parallel without global lock contention.

Get Updates Become a Design Partner

// Updates on Stategraph development, no spam