Terraform State on AWS | S3 Backend & DynamoDB Locking
Local Terraform state files create chaos in teams. Two developers run terraform apply
simultaneously and corrupt the state. Someone's laptop containing the only state copy is broken. A new team member accidentally commits the state file to Git, exposing database passwords and private keys. These are common problems in teams without proper state management.
In this guide, you'll learn how to prevent state conflicts and enable team collaboration. By the end, you'll understand how state management can be scaled from a couple of developers to a hundred.
No more corruption. No more conflicts. No more manually fixing drift because someone forgot to pull the latest state.
What is Terraform State?
Terraform state is the bridge between your configuration files and the real infrastructure running in your cloud account. When you write Terraform configuration describing an EC2 instance or RDS database, Terraform needs a way to track that these resources actually exist and match what you've defined.
The state file serves as this critical record, mapping each resource in your configuration to its real-world counterpart through unique identifiers, current properties, and dependency relationships.
Think of state as Terraform's memory. Without it, Terraform would have no idea whether that EC2 instance you defined already exists or needs to be created. It wouldn't know the instance ID to use when you change the instance type. It couldn't determine the correct order for destroying resources when you remove them from configuration.
The state file, typically named terraform.tfstate
, stores all this essential metadata as JSON. When you run terraform plan
, Terraform reads this state to understand the current infrastructure, compares it against your desired configuration, and calculates the precise changes needed to align reality with your code.
Where to store Terraform state files
By default, Terraform stores state locally in a terraform.tfstate
file in your project directory.
However, this approach breaks down the moment a second developer joins your project. Without a centralized location, team members work with different versions of the state, leading to conflicts, resource duplication, and the dreaded "resource already exists" errors.
Remote state storage solves this issue and makes collaborative infrastructure development possible.
AWS has a good solution for remote state storage: S3 for the state files and DynamoDB for locking.
S3 provides eleven nines of durability (99.999999999%), which means your state files are safer there than on any local machine or even most other storage systems.
Built-in versioning lets you recover from accidental corruption or deletions. When someone makes a mistake during a complex refactoring, you can roll back to a previous state version and recover your infrastructure's known-good configuration.
The integration with IAM enables you to control exactly who can read or modify state files, enforcing your team's access policies at the storage layer.
DynamoDB adds a locking mechanism that prevents concurrent modifications. When an engineer runs terraform apply
, Terraform writes a lock entry to DynamoDB that includes:
- Who's making changes
- What operation they're running
- When it started
Other team members running Terraform see this lock and wait, preventing the race conditions that corrupt state when multiple people modify infrastructure simultaneously.
Your state files need thoughtful organization as your infrastructure grows (for more on structuring your Terraform, see our guide on code organization). A flat structure works initially, but you'll quickly need separation between environments and infrastructure components:
s3://company-terraform-state/
├── production/
│ ├── networking/terraform.tfstate
│ ├── compute/terraform.tfstate
│ └── databases/terraform.tfstate
├── staging/
│ └── terraform.tfstate
└── development/
└── terraform.tfstate
This hierarchy keeps production changes isolated from development experiments while allowing teams to work on different infrastructure layers independently.
State files contain sensitive information that requires protection beyond basic access control. They store database passwords, API keys, and private IP addresses in plain text. S3's server-side encryption protects data at rest, while SSL/TLS secures data in transit. Here are three ways of securing your state file:
- Configure bucket policies that block all public access, even if someone accidentally misconfigures an object ACL.
- Set up S3 access logging to create an audit trail of who accessed state files and when, which proves invaluable during security reviews or incident investigations.
- Implement least-privilege IAM policies, enabling developers to modify development state but only read production state, reducing the blast radius of compromised credentials or mistaken changes.
How to manage Terraform state on AWS
Setting up remote state storage requires an S3 bucket and DynamoDB table with specific configurations. The bucket needs versioning to recover from corruption, encryption to protect sensitive data, and lifecycle policies to manage costs, while the DynamoDB table only needs to track lock ownership:
resource "aws_s3_bucket" "terraform_state" {
bucket = "company-terraform-state"
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
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"
}
}
}
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
}
resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
id = "delete-old-versions"
status = "Enabled"
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}
resource "aws_dynamodb_table" "terraform_locks" {
name = "terraform-state-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
This configuration gives you:
- Versioning for recovery
- Encryption for security
- Public access blocking for protection
- Lifecycle rules for cost control
- Pay-per-request DynamoDB billing, since lock operations happen infrequently
With infrastructure ready, configure your Terraform backend:
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
The key
parameter organizes state files by environment and component. Use patterns like {environment}/{component}/terraform.tfstate
to keep states isolated – never share state files between environments or you'll accidentally destroy production while working on development.
For multiple environments, use partial configuration with backend config files:
# backend-prod.hcl
bucket = "company-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
Then initialize with: terraform init -backend-config=backend-prod.hcl
How to bootstrap your Terraform state setup
Creating your remote state backend comes with a challenge: Terraform needs the S3 bucket and DynamoDB table to store its state, but you need Terraform to create those resources in the first place. This circular dependency may seem like a showstopper, but there are three ways to solve this issue.
Method 1: Two-stage deployment
The two-stage approach keeps everything in Terraform.
You create the backend infrastructure using local state first, then migrate your main infrastructure to use it.
Start by applying the S3 and DynamoDB configuration from a separate directory with local state. Once these resources exist, configure your main Terraform project to use them as its backend.
During initialization, Terraform detects the new backend configuration and migrates your local state to S3 automatically.
mkdir terraform-backend && cd terraform-backend
# Add the S3 and DynamoDB resources here
terraform init
terraform apply
# Now set up your main infrastructure
cd ../main-infrastructure
terraform init -backend-config="bucket=company-terraform-state" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=us-east-1" \
-backend-config="dynamodb_table=terraform-state-locks"
Method 2: Manual AWS resource creation
Sometimes the fastest path is creating resources directly through AWS CLI, especially for a one-time setup.
This manual approach trades Terraform's declarative benefits for immediate results. You run AWS CLI commands to create the S3 bucket with versioning and encryption, then create the DynamoDB table.
While you lose infrastructure-as-code for the backend itself, this method works when you need backend infrastructure immediately or when bootstrapping new AWS accounts.
# Create and configure S3 bucket
aws s3 mb s3://company-terraform-state
aws s3api put-bucket-versioning --bucket company-terraform-state \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket company-terraform-state \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket company-terraform-state \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# Create DynamoDB table
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
Method 3: Dedicated bootstrap module
A reusable Terraform module provides the most scalable approach for organizations managing multiple AWS accounts.
You create a module that accepts environment parameters and outputs the backend configuration details. Deploy it once per environment using local state, then share the output configuration with your team.
This method combines infrastructure-as-code benefits with reusability across projects.
For existing projects with local state, always create a backup before starting. Add the backend configuration to your Terraform code, then run terraform init
with the -migrate-state
flag. Terraform will copy your local state to S3 and configure DynamoDB locking.
After migration, verify the state transferred correctly by running terraform state list
and comparing it against your resources.
# Backup current state
cp terraform.tfstate terraform.tfstate.backup
# Add backend configuration
cat > backend.tf << 'EOF'
terraform {
backend "s3" {}
}
EOF
# Migrate state
terraform init -migrate-state \
-backend-config="bucket=company-terraform-state" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=us-east-1" \
-backend-config="dynamodb_table=terraform-state-locks"
# Verify migration worked
terraform state list
If you need to import existing resources that aren't in your state, check our guide on Terraform state import. For detailed migration strategies, see our guide on Terraform state migration
How to integrate your Terraform state setup with CI workflows
Your CI/CD pipeline needs the same state access your local machine has, but with proper automation and security controls (for CI/CD practices, see our detailed guide).
The pipeline must handle backend initialization dynamically, based on the target environment, while keeping production isolated from development changes.
GitHub Actions provides the automation framework, using repository variables for configuration and branch names to determine which state path to use. Here is a full workflow example:
name: Terraform Deploy
on:
push:
branches: [main, staging, development]
env:
AWS_REGION: us-east-1
TF_VERSION: 1.5.0
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: |
terraform init \
-backend-config="bucket=company-terraform-state" \
-backend-config="key=${{ github.ref_name }}/terraform.tfstate"\
-backend-config="region=${{ env.AWS_REGION }}" \
-backend-config="dynamodb_table=terraform-state-locks"
- name: Terraform Plan
run: terraform plan -out=tfplan
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply tfplan
The workflow uses the branch name as part of the state key, keeping environments isolated. For production protection, add a separate workflow with manual approval:
- name: Terraform Apply Production
if: github.ref == 'refs/heads/main'
environment: production
run: terraform apply -auto-approve -lock-timeout=30m
If locks get stuck, add a manual force-unlock job that only runs on workflow_dispatch (for more details, see our guide on Terraform state unlock):
on:
workflow_dispatch:
inputs:
lock_id:
description: 'Lock ID to force unlock'
required: true
jobs:
force-unlock:
runs-on: ubuntu-latest
steps:
- name: Force Unlock
run: |
terraform force-unlock -force ${{ github.event.inputs.lock_id }}
Common pitfalls
Common pitfalls surface quickly in CI environments. Here are a few that can occur and how to avoid them:
- Long-running applies often hit Terraform's default 15-minute lock timeout, causing deployments to fail partway through. Complex infrastructures need more time, so set
-lock-timeout=30m
for production deployments. - IAM permissions cause frequent issues when roles have incomplete access. The role needs both
s3:GetObject
ands3:PutObject
permissions for state operations. Eventerraform plan
writes metadata to the state file, so read-only permissions will cause failures. - State path collisions create the most dangerous problems. If two environments share the same state path, deploying to development can destroy production resources. Each environment must have its own unique key path in S3.
When configured correctly, state management fades into the background. Your team opens pull requests, reviews plans, and merges changes without thinking about locks, conflicts, or corruption. The infrastructure handles the complexity while developers focus on building.
Conclusion
Remote state transforms Terraform from a single-user tool into a team platform.
S3 provides durable, versioned storage that survives laptop failures and employee departures.
DynamoDB's atomic locking eliminates race conditions when multiple pipelines run simultaneously.
Together, they create a foundation for infrastructure automation that scales with your organization.
You've built more than just remote storage. S3 versioning gives you an audit trail of every infrastructure change. State backups provide disaster recovery options when things go wrong.
Your developers can work on different components simultaneously without coordination overhead or stepping on each other's changes. The infrastructure handles the complexity of distributed state management while your team focuses on building.
As your infrastructure grows, consider how Terrateam eliminates the remaining manual work.
Terrateam handles backend configuration automatically without copy-pasting bucket names or managing backend config files. It provides intelligent locking that understands dependencies between resources, drift detection that catches manual changes, and policy enforcement that prevents dangerous operations.
The state management principles stay the same, but the implementation complexity disappears.
Ready to move beyond manual state management? Sign up for Terrateam and let automation handle the complexity while you focus on building infrastructure.