Terraform Remote State Management: S3 Backend with DynamoDB Locking Explained

If you have used Terraform for more than a day, you have met the terraform.tfstate file. At first it feels harmless — just a JSON snapshot of what Terraform thinks exists in your infrastructure. But the moment a second person on your team runs terraform apply, or a CI pipeline fires at the same time as a manual run, that file becomes a landmine.

Local state does not scale. Remote state does — but only if you set it up correctly.

This post walks through the canonical AWS solution: storing Terraform state in S3 for durability and sharing, and using DynamoDB to enforce state locking so that only one operation can modify state at a time.


Why Local State Breaks in Teams

Before configuring anything, it is worth being precise about what can go wrong.

No sharing. terraform.tfstate lives on one machine. A teammate running plan or apply has no idea what you just changed. Terraform compares its desired state against the real world; without an accurate state file, it will try to recreate resources that already exist.

No locking. Two concurrent apply runs read the same state, each plans against it, and each writes back a conflicting result. This is a classic lost-update race condition. The second writer silently overwrites the first writer’s state, leaving Terraform with an inaccurate picture of your infrastructure.

Accidental deletion. State files committed to Git get lost in merges, are deleted with the repo, or — worse — contain sensitive values like database passwords that should never be in version control.

Remote state solves all three: a shared, durable, locked store that every operator and every CI runner reads from and writes to.


Architecture Overview

┌─────────────────────────────────────────────────────┐
│                   Terraform Client                  │
│           (local, CI runner, teammate)              │
└────────────────────┬────────────────────────────────┘
                     │
          ┌──────────▼──────────┐
          │   DynamoDB Table    │  ← acquires lock before read/write
          │  (state locking)    │
          └──────────┬──────────┘
                     │ lock acquired
          ┌──────────▼──────────┐
          │     S3 Bucket       │  ← reads / writes terraform.tfstate
          │  (state storage)    │
          │  versioning enabled │
          └─────────────────────┘

The flow on every plan or apply:

  1. Terraform contacts DynamoDB and writes a lock item containing the operation, operator identity, and timestamp.
  2. If another lock item already exists, Terraform prints the lock info and exits — no state is read or written.
  3. Once the lock is acquired, Terraform reads state from S3, performs the operation, writes updated state back to S3.
  4. Terraform releases the lock by deleting the DynamoDB item.

Step 1 — Create the S3 Bucket

The bucket must exist before Terraform can use it as a backend. Bootstrap it with the AWS CLI or a separate “bootstrap” Terraform root module that you apply once and never touch again.

aws s3api create-bucket \
  --bucket my-company-terraform-state \
  --region us-east-1

# Enable versioning — gives you a full history and easy rollback
aws s3api put-bucket-versioning \
  --bucket my-company-terraform-state \
  --versioning-configuration Status=Enabled

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

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

If you prefer KMS encryption (recommended for regulated environments):

aws s3api put-bucket-encryption \
  --bucket my-company-terraform-state \
  --server-side-encryption-configuration '{
    "Rules": [{
      "ApplyServerSideEncryptionByDefault": {
        "SSEAlgorithm": "aws:kms",
        "KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789012:key/your-key-id"
      }
    }]
  }'

Step 2 — Create the DynamoDB Table

The table needs a single string attribute named LockID as its partition key. That is all Terraform requires.

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 \
  --region us-east-1

PAY_PER_REQUEST (on-demand) is almost always the right billing mode here. State lock operations are infrequent and bursty; provisioned capacity would be wasted money.


Managing the bucket and table with Terraform itself is cleaner than raw CLI commands and gives you a paper trail. Keep this in a dedicated bootstrap/ directory that is applied once by an admin.

# bootstrap/main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

# ── S3 Bucket ────────────────────────────────────────────────────────────────

resource "aws_s3_bucket" "terraform_state" {
  bucket = var.state_bucket_name

  # Prevent accidental deletion of the state bucket
  lifecycle {
    prevent_destroy = true
  }

  tags = {
    Name        = "Terraform State"
    Environment = "shared"
    ManagedBy   = "terraform"
  }
}

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
}

# Lifecycle rule: expire old non-current versions after 90 days
resource "aws_s3_bucket_lifecycle_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  rule {
    id     = "expire-old-state-versions"
    status = "Enabled"

    noncurrent_version_expiration {
      noncurrent_days = 90
    }
  }
}

# ── DynamoDB Lock Table ───────────────────────────────────────────────────────

resource "aws_dynamodb_table" "terraform_locks" {
  name         = var.lock_table_name
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }

  lifecycle {
    prevent_destroy = true
  }

  tags = {
    Name        = "Terraform State Locks"
    Environment = "shared"
    ManagedBy   = "terraform"
  }
}
# bootstrap/variables.tf

variable "aws_region" {
  description = "AWS region for state infrastructure"
  type        = string
  default     = "us-east-1"
}

variable "state_bucket_name" {
  description = "Name of the S3 bucket for Terraform state"
  type        = string
}

variable "lock_table_name" {
  description = "Name of the DynamoDB table for state locking"
  type        = string
  default     = "terraform-state-locks"
}
# bootstrap/outputs.tf

output "state_bucket_name" {
  value = aws_s3_bucket.terraform_state.bucket
}

output "state_bucket_arn" {
  value = aws_s3_bucket.terraform_state.arn
}

output "lock_table_name" {
  value = aws_dynamodb_table.terraform_locks.name
}

output "lock_table_arn" {
  value = aws_dynamodb_table.terraform_locks.arn
}

Apply with local state — this is the only module that can bootstrap itself:

cd bootstrap/
terraform init
terraform apply \
  -var="state_bucket_name=my-company-terraform-state"

Step 4 — Configure the Remote Backend

With the bucket and table in place, configure the backend in your project’s root module:

# main.tf (or backend.tf)

terraform {
  required_version = ">= 1.6.0"

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

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

The key value is the S3 object path for this module’s state file. Design it to encode enough context to be unambiguous at a glance:

{environment}/{region}/{component}/terraform.tfstate

prod/us-east-1/vpc/terraform.tfstate
prod/us-east-1/eks-cluster/terraform.tfstate
staging/eu-west-1/rds/terraform.tfstate

Initialise the backend:

terraform init

Terraform will prompt you to confirm migration if you have existing local state. Confirm, and it copies terraform.tfstate to S3 automatically.


Step 5 — IAM Permissions

Every operator and CI role needs the right permissions. Apply least privilege: grant only what Terraform actually needs.

# iam/terraform_state_policy.tf

data "aws_iam_policy_document" "terraform_state" {
  # S3: read and write state objects
  statement {
    sid    = "StateObjectAccess"
    effect = "Allow"
    actions = [
      "s3:GetObject",
      "s3:PutObject",
      "s3:DeleteObject",
    ]
    resources = ["arn:aws:s3:::my-company-terraform-state/*"]
  }

  # S3: list bucket (required for key lookup)
  statement {
    sid       = "StateBucketList"
    effect    = "Allow"
    actions   = ["s3:ListBucket"]
    resources = ["arn:aws:s3:::my-company-terraform-state"]
  }

  # DynamoDB: acquire and release locks
  statement {
    sid    = "StateLocking"
    effect = "Allow"
    actions = [
      "dynamodb:GetItem",
      "dynamodb:PutItem",
      "dynamodb:DeleteItem",
    ]
    resources = ["arn:aws:dynamodb:us-east-1:123456789012:table/terraform-state-locks"]
  }
}

resource "aws_iam_policy" "terraform_state" {
  name        = "TerraformStateAccess"
  description = "Allows Terraform to read/write remote state and acquire locks"
  policy      = data.aws_iam_policy_document.terraform_state.json
}

Attach this policy to your CI role, your developer IAM users or roles, and any assumed role used in automated pipelines.

Tip for CI: Scope the S3 resources to a prefix matching only the environments the pipeline should touch:

# CI role for staging only
resources = ["arn:aws:s3:::my-company-terraform-state/staging/*"]

Step 6 — Workspaces for Environment Isolation

Terraform workspaces let a single configuration manage multiple environments without duplicating code. Each workspace gets its own state file in S3 under a env:/ prefix automatically:

env:/staging/prod/us-east-1/vpc/terraform.tfstate
env:/production/prod/us-east-1/vpc/terraform.tfstate
# Create and switch to a staging workspace
terraform workspace new staging
terraform workspace select staging
terraform apply

# Switch to production
terraform workspace new production
terraform workspace select production
terraform apply

Reference the current workspace in your configuration to vary behaviour per environment:

locals {
  env = terraform.workspace

  instance_type = {
    staging    = "t3.small"
    production = "m5.large"
  }
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = local.instance_type[local.env]

  tags = {
    Environment = local.env
  }
}

When not to use workspaces: If your environments have meaningfully different configurations, separate root modules (each with their own backend key) are cleaner than a single module stretched across environments with conditional logic.


Reading Remote State Across Modules

Large infrastructures split into multiple Terraform root modules — one for networking, one for the database layer, one for the application. Modules need to share outputs (like a VPC ID) without being directly connected.

The terraform_remote_state data source reads another module’s state file from S3:

# In your application module — read outputs from the VPC module
data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "my-company-terraform-state"
    key    = "prod/us-east-1/vpc/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_instance" "app" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "m5.large"
  subnet_id     = data.terraform_remote_state.vpc.outputs.private_subnet_ids[0]
}

This creates an implicit dependency between modules — the VPC module must be applied before the application module. Make this explicit in your CI pipeline by sequencing the applies.


Handling a Stuck Lock

If a Terraform process is killed mid-apply, the DynamoDB lock item is never deleted. Future runs will fail with:

Error: Error acquiring the state lock

  Lock Info:
    ID:        a1b2c3d4-...
    Path:      my-company-terraform-state/prod/us-east-1/vpc/terraform.tfstate
    Operation: OperationTypeApply
    Who:       user@host
    Version:   1.6.0
    Created:   2026-06-27 10:31:00 +0000 UTC

Verify no apply is actually running (check your CI dashboard and ask your team), then force-unlock using the ID shown in the error:

terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890

Force-unlock does not roll back any infrastructure changes — it only removes the lock record. Always confirm the previous operation is truly dead before unlocking.


State Security Checklist

State files often contain sensitive values: database passwords, API keys, private keys written by tls_private_key. Treat the S3 bucket with the same sensitivity as your secrets manager.

  • Encryption at rest — AES-256 or KMS on the bucket (configured above).
  • Encryption in transit — S3 and DynamoDB use HTTPS by default; enforce it with a bucket policy denying aws:SecureTransport = false.
  • No public access — the public access block is non-negotiable.
  • Versioning — enables point-in-time recovery if state is corrupted or accidentally deleted.
  • Access logging — enable S3 server access logs to a separate bucket so you have an audit trail of who read or wrote state.
  • Least-privilege IAM — scope the state policy to the bucket and table ARNs, not *.
  • Avoid sensitive values in state — use sensitive = true on outputs and prefer fetching secrets from AWS Secrets Manager at runtime rather than storing them in resource attributes.
# Enforce HTTPS-only access to the state bucket
resource "aws_s3_bucket_policy" "terraform_state_https_only" {
  bucket = aws_s3_bucket.terraform_state.id

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

CI/CD Integration

A reliable pipeline pattern for remote state:

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main]
  pull_request:

permissions:
  id-token: write   # OIDC for AWS authentication
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    env:
      TF_WORKSPACE: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/TerraformCI
          aws-region: us-east-1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.8.0"

      - name: Terraform Init
        run: terraform init

      - name: Terraform Plan
        run: terraform plan -out=tfplan

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve tfplan

Key decisions in this workflow:

  • OIDC authentication — no long-lived AWS credentials in GitHub secrets. The CI role is assumed via a web identity token scoped to the repository.
  • Workspace from branch — pull requests plan against staging; merges to main apply to production.
  • Plan output fileterraform plan -out=tfplan saves the plan and terraform apply tfplan executes exactly that plan, not a re-plan that could differ.

Key Takeaways

Remote state with S3 and DynamoDB is the baseline for any Terraform setup used by more than one person. The configuration is not complex, but the details — versioning, encryption, least-privilege IAM, HTTPS-only bucket policies — are what make it production-grade rather than just functional.

The principles to carry forward:

  1. Bootstrap separately. The bucket and table are prerequisites; manage them in an isolated module applied once by an admin.
  2. Make state keys meaningful. {env}/{region}/{component}/terraform.tfstate gives you instant context when browsing S3.
  3. Scope IAM tightly. CI roles should only access the state paths they own.
  4. Never skip versioning. State corruption happens; versioning is your rollback.
  5. Force-unlock carefully. Always confirm the previous operation is dead before releasing a lock.
  6. Treat state as a secret. It probably contains credentials. Encrypt it, restrict it, and audit access to it.