October 17, 2025josh-pollara

Terraform State on AWS | S3 Backend & DynamoDB Locking

What you'll learn: This guide walks you through setting up bulletproof remote state storage on AWS. You'll configure S3 for state storage with versioning and encryption, set up DynamoDB for atomic locking, solve the bootstrap paradox of creating your backend infrastructure, and integrate everything with GitHub Actions for automated deployments.

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 and s3:PutObject permissions for state operations. Even terraform 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.