Skip to content
Infrastructure as Code iac state 4 min read

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.

MechanismHow it worksWhen to use
DynamoDB tableA LockID item is written to the table for the duration of a runMature, widely supported in older Terraform and OpenTofu versions
S3 native lockfileA .tflock object is written next to the state using S3 conditional writesTerraform 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 key per 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_version pinned so collaborators and CI use a compatible CLI when reading and writing shared state.
Last updated June 14, 2026
Was this helpful?