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:
- Terraform contacts DynamoDB and writes a lock item containing the operation, operator identity, and timestamp.
- If another lock item already exists, Terraform prints the lock info and exits — no state is read or written.
- Once the lock is acquired, Terraform reads state from S3, performs the operation, writes updated state back to S3.
- 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.
Step 3 — Bootstrap with Terraform (Recommended)
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 = trueon 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 file —
terraform plan -out=tfplansaves the plan andterraform apply tfplanexecutes 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:
- Bootstrap separately. The bucket and table are prerequisites; manage them in an isolated module applied once by an admin.
- Make state keys meaningful.
{env}/{region}/{component}/terraform.tfstategives you instant context when browsing S3. - Scope IAM tightly. CI roles should only access the state paths they own.
- Never skip versioning. State corruption happens; versioning is your rollback.
- Force-unlock carefully. Always confirm the previous operation is dead before releasing a lock.
- Treat state as a secret. It probably contains credentials. Encrypt it, restrict it, and audit access to it.