Every Terraform project on AWS eventually hits the same wall: the state file. Keep it on your laptop and the first teammate who runs terraform apply from their own machine will happily try to recreate everything you already built. This guide walks through the setup I use on real projects: a hardened S3 bucket for state, native S3 state locking, and a clean bootstrap process that avoids the classic chicken-and-egg problem.
What you need
- Terraform 1.11 or newer (native S3 locking is generally available from 1.11)
- The AWS CLI, signed in to the account you are deploying to (
aws sts get-caller-identityshould return your identity) - Permission to create an S3 bucket and, later, IAM roles
Why remote state matters
Terraform's state file maps the resources in your code to the real resources in AWS. It also often contains sensitive values such as database passwords and generated keys. A good state backend gives you four things:
- One source of truth that the whole team and your CI pipeline share
- Locking, so two
applyruns can never modify the same state at the same time - History, so a bad change to the state can be rolled back
- Encryption and access control, because state can contain secrets
S3 covers all four. The bucket stores the state, versioning gives you history, and since Terraform 1.10 the S3 backend can lock state with a lock file in the same bucket. You no longer need a separate DynamoDB table; the dynamodb_table setting is now deprecated.
Step 1: Bootstrap the state bucket
The bucket that holds your state has to exist before Terraform can use it. I keep it in a small, separate bootstrap configuration that is applied once with local state.
# bootstrap/main.tf
terraform {
required_version = ">= 1.11"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "ca-central-1"
}
data "aws_caller_identity" "current" {}
resource "aws_s3_bucket" "tfstate" {
# Bucket names are global, so include the account ID to keep it unique
bucket = "tfstate-${data.aws_caller_identity.current.account_id}-ca-central-1"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Refuse any request that is not made over TLS
resource "aws_s3_bucket_policy" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [
aws_s3_bucket.tfstate.arn,
"${aws_s3_bucket.tfstate.arn}/*",
]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
}]
})
}
# Keep old state versions for 90 days, then clean them up
resource "aws_s3_bucket_lifecycle_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
id = "expire-old-state-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}
output "state_bucket" {
value = aws_s3_bucket.tfstate.bucket
}
Apply it once:
cd bootstrap
terraform init
terraform apply
A few choices worth explaining:
prevent_destroystops an accidentalterraform destroyfrom wiping out every project's state.- Versioning is your undo button. If the state ever gets corrupted, you can restore an earlier version from the S3 console.
- SSE-KMS with a bucket key encrypts state at rest without adding noticeable KMS cost. If you need tighter control, create a customer-managed KMS key and pass its ARN as
kms_master_key_id. - New S3 buckets already disable ACLs and block public access by default. Declaring the public access block anyway makes that intent explicit and catches drift.
terraform init -migrate-state. Terraform copies the local state into the bucket it just created.Step 2: Point your projects at the bucket
In each real project, add an S3 backend block. Give each project and environment its own key so their state files never collide.
# infra/networking/backend.tf
terraform {
required_version = ">= 1.11"
backend "s3" {
bucket = "tfstate-123456789012-ca-central-1"
key = "networking/prod/terraform.tfstate"
region = "ca-central-1"
encrypt = true
use_lockfile = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
use_lockfile = true is the important line. During every plan and apply, Terraform writes a networking/prod/terraform.tfstate.tflock object next to the state, using S3 conditional writes so only one run can hold the lock. If a second run starts, it waits or fails with a lock error instead of corrupting your state.
Backend blocks cannot use variables. To reuse one configuration across environments, leave out the environment-specific values and pass them at init time:
terraform init -backend-config="key=networking/dev/terraform.tfstate"
Step 3: Give people and pipelines least-privilege access
Whoever runs Terraform needs access to the state objects and the lock file, and nothing else in the bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::tfstate-123456789012-ca-central-1",
"Condition": { "StringLike": { "s3:prefix": "networking/*" } }
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": [
"arn:aws:s3:::tfstate-123456789012-ca-central-1/networking/*"
]
}
]
}
s3:DeleteObject is needed because Terraform deletes the .tflock file when it releases the lock. Scoping the policy to a prefix like networking/* means a pipeline for one project can't read the state of another, which matters because state can contain secrets.
Step 4: Migrate an existing project
If a project already has local state, migration takes one command. Add the backend block, then run:
terraform init -migrate-state
terraform plan # should report: No changes.
A clean plan proves the state was copied correctly. Once it is, delete the local terraform.tfstate and terraform.tfstate.backup files and make sure your .gitignore covers them:
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
Troubleshooting
- "Error acquiring the state lock": another run is in progress, or a crashed run left a lock behind. Check your CI first. If you are certain nothing else is running, release it with
terraform force-unlock LOCK_ID, using the ID from the error message. - **AccessDenied on the
.tflockobject**: the IAM policy is missings3:PutObjectors3:DeleteObjecton the lock file path. - Moving off DynamoDB locking: you can set
use_lockfile = truealongsidedynamodb_tableduring the transition. Terraform then takes both locks. Once every pipeline uses the new configuration, removedynamodb_tableand delete the table.
Wrapping up
With an S3 bucket that is versioned, encrypted and TLS-only, plus use_lockfile, you get a production-grade Terraform backend on AWS with no extra infrastructure to run. In the next articles I set up the same foundation on Azure, then connect both clouds to a CI/CD pipeline that doesn't need any stored cloud credentials.