S3 Backend
The S3 backend is the most widely used remote backend for Terraform on AWS. It stores your state file in an S3 bucket, encrypts it at rest, and coordinates concurrent runs through state locking so two engineers (or two CI jobs) never corrupt the state by writing at the same time. Because it relies only on standard AWS primitives — a bucket and a lock — it is cheap, durable, and works equally well with OpenTofu, which shares the same backend implementation and configuration syntax.
How the S3 backend works
When you configure the S3 backend, Terraform writes terraform.tfstate to a key (path) inside an S3 bucket instead of keeping it on local disk. Every plan and apply reads the latest state from S3 and writes the updated state back. Three pieces define where and how the state lives:
- bucket — the S3 bucket that holds the state object.
- key — the object path within the bucket (for example
prod/network/terraform.tfstate). - region — the AWS region the bucket lives in.
The bucket should already exist before you initialize the backend — Terraform does not create the backend bucket for you (a deliberate chicken-and-egg safeguard). Most teams provision the bucket and lock table once with a small bootstrap configuration that uses local state.
Creating the backend infrastructure
This bootstrap configuration creates a versioned, encrypted bucket and a DynamoDB table for locking. Run it once, with local state.
resource "aws_s3_bucket" "tfstate" {
bucket = "acme-terraform-state"
}
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"
}
}
}
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
}
resource "aws_dynamodb_table" "tflock" {
name = "acme-terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Always enable bucket versioning. If a corrupt or truncated state is written, versioning lets you roll back to the previous object version — this is your last line of defense against losing state.
Configuring the backend
Once the bucket and table exist, point your real configuration at them inside the terraform block.
terraform {
required_version = ">= 1.5"
backend "s3" {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-terraform-locks"
encrypt = true
}
}
Setting encrypt = true ensures Terraform requests server-side encryption when uploading state, complementing the bucket’s default encryption policy. After saving the file, run terraform init to migrate.
terraform init
Output:
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Terraform has been successfully initialized!
If you are switching from local state, Terraform detects the existing terraform.tfstate and prompts to copy it into S3.
State locking
Without locking, two simultaneous applies can overwrite each other’s state. Terraform supports two locking mechanisms for the S3 backend.
| Mechanism | How it works | When to use |
|---|---|---|
| DynamoDB table | A LockID item is written to the table for the duration of a run | Mature, widely supported in older Terraform and OpenTofu versions |
| S3 native lockfile | A .tflock object is written next to the state using S3 conditional writes | Terraform 1.10+ / OpenTofu 1.10+; removes the DynamoDB dependency |
To use S3 native locking instead of DynamoDB, drop dynamodb_table and add use_lockfile:
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
When a lock is held and someone else runs apply, Terraform reports it clearly rather than racing.
Output:
Error: Error acquiring the state lock
Lock Info:
ID: 9f3c1a2e-7b4d-4e21-9a0f-12c3d4e5f678
Path: acme-terraform-state/prod/network/terraform.tfstate
Operation: OperationTypeApply
Who: dev@laptop
Created: 2026-06-14 09:41:22 UTC
If a process crashes mid-apply, a stale lock can remain. Clear it with
terraform force-unlock <LOCK_ID>— but only after confirming no other run is actually in progress.
Worked example: per-environment keys
A common pattern is one bucket with a different key per environment, keeping state isolated while sharing infrastructure. Use partial configuration so the static settings live in code and the changing key is passed at init time.
terraform {
backend "s3" {
bucket = "acme-terraform-state"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
}
Then initialize each environment with its own key:
terraform init -backend-config="key=staging/network/terraform.tfstate"
This lets the same module power dev, staging, and prod, each writing to a distinct object — far safer than sharing one state file across environments.
Best Practices
- Provision the backend bucket and lock table in a dedicated bootstrap configuration, then never touch them from the configurations that consume them.
- Enable bucket versioning and KMS encryption, and apply a public access block so state is never readable outside your account.
- Use a distinct
keyper environment and per component to keep blast radius small and locks granular. - Prefer S3 native locking (
use_lockfile = true) on Terraform/OpenTofu 1.10+ to avoid running and paying for a DynamoDB table. - Restrict IAM to the specific bucket, key prefixes, and KMS key — grant write only to the pipelines that own each state.
- Keep
required_versionpinned so collaborators and CI use a compatible CLI when reading and writing shared state.