Skip to content

AWS Remote State Architecture


A remote backend is useful only when it is designed like real infrastructure. For an AWS-based Terraform team, that usually means thinking about the S3 bucket holding state, who can access it, how concurrent operations are locked, and how an accidental deletion can be recovered.

This chapter builds a small AWS remote-state setup and walks through the bootstrap problem from start to finish.

The AWS State Architecture

The architecture we’ll build is intentionally small:

                    Terraform
                        β”‚
                        β”‚ S3 backend
                        β–Ό
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β”‚ S3 State Bucket β”‚
                β”‚                 β”‚
                β”‚ State object    β”‚
                β”‚ Lock object     β”‚
                β”‚ Version history β”‚
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        β”‚
                        β–Ό
                 AWS Infrastructure

The S3 bucket is not application infrastructure. It is infrastructure that protects and coordinates Terraform’s own state.

For the current S3 backend, Terraform supports state locking with an S3 lock file using:

use_lockfile = true

The older DynamoDB-based locking mechanism is deprecated. It remains relevant when migrating older installations, but new configurations should use the current S3 locking capability unless there is a specific compatibility reason not to.

What Should the State Bucket Provide?

For a serious state bucket, you generally want:

  • A dedicated bucket
  • Encryption
  • Versioning
  • Restricted access
  • State locking
  • A predictable state-key layout
  • Recovery procedures
  • Minimal permissions for Terraform identities

A useful mental model is:

S3 Bucket
β”‚
β”œβ”€β”€ encryption
β”‚
β”œβ”€β”€ versioning
β”‚
β”œβ”€β”€ access control
β”‚
β”œβ”€β”€ state objects
β”‚
└── .tflock objects

The state object and lock object have different purposes.

For a key such as:

prod/network/terraform.tfstate

the S3 backend can use a corresponding lock file:

prod/network/terraform.tfstate.tflock

The Bootstrap Problem

There is a circular dependency if Terraform is asked to create the bucket that Terraform needs as its backend.

The solution is to bootstrap the backend separately.

We’ll use:

terraform-state-bootstrap/
β”œβ”€β”€ main.tf
β”œβ”€β”€ variables.tf
└── outputs.tf

This configuration will initially use local state.

It creates the S3 bucket.

Then a second configuration will use that bucket as its backend.

Note

In a real organization, the bootstrap configuration may live in a separate repository or be owned by a platform team or even created manually. The important concept is the lifecycle boundary, not the exact repository arrangement.

Prerequisites

You need:

  • An AWS account
  • AWS credentials that can create the bootstrap resources
  • Terraform installed
  • The AWS provider available to Terraform

You should also have an AWS region selected.

For this example, use:

export AWS_REGION=us-east-1

The AWS provider can use the standard AWS credential chain. Do not put access keys in Terraform source code. For more information, see AWS docs.

Build the Bootstrap Configuration

Create a directory:

mkdir terraform-state-bootstrap
cd terraform-state-bootstrap

Create main.tf:

terraform {
  required_version = ">= 1.11.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

resource "aws_s3_bucket" "terraform_state" {
  bucket = var.bucket_name
}

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
}

Create variables.tf:

variable "aws_region" {
  description = "AWS region containing the Terraform state bucket."
  type        = string
  default     = "us-east-1"
}

variable "bucket_name" {
  description = "Globally unique S3 bucket name for Terraform state."
  type        = string
}

Create outputs.tf:

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

Your directory should now look like:

terraform-state-bootstrap/
β”œβ”€β”€ main.tf
β”œβ”€β”€ outputs.tf
└── variables.tf

Create the State Bucket

Choose a globally unique bucket name.

For example:

terraform init

Then:

terraform plan \
  -var='bucket_name=devstuffs-terraform-state-example'

If the plan looks correct:

terraform apply \
  -var='bucket_name=devstuffs-terraform-state-example'

Terraform will create the bucket using the local backend.

At this point, the bootstrap configuration has a local state file.

That is intentional.

Bootstrap Terraform
        β”‚
        β–Ό
 Local terraform.tfstate
        β”‚
        β–Ό
Creates S3 bucket

Do not confuse this bootstrap state with the state for the infrastructure that will eventually use the bucket.

Create a Configuration That Uses the Backend

Now create a separate directory:

cd ..
mkdir terraform-remote-state-demo
cd terraform-remote-state-demo

Create backend.tf:

terraform {
  required_version = ">= 1.11.0"

  backend "s3" {
    bucket       = "devstuffs-terraform-state-example"
    key          = "demo/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
  }

  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

The important part is:

backend "s3" {
  bucket       = "devstuffs-terraform-state-example"
  key          = "demo/terraform.tfstate"
  region       = "us-east-1"
  use_lockfile = true
}

The current S3 backend supports native S3 lock files through use_lockfile. The older DynamoDB locking configuration is deprecated. ξˆ€citeξˆ‚turn0search0

Create a Small Resource

Create main.tf:

resource "aws_s3_bucket" "example" {
  bucket = "devstuffs-terraform-state-demo-resource"
}

Your directory is now:

terraform-remote-state-demo/
β”œβ”€β”€ backend.tf
└── main.tf

Choose a globally unique name for the example bucket if necessary.

Now initialize:

terraform init

Terraform connects to the remote backend.

You can verify the configuration with:

terraform plan

Notice something important: the state for this configuration is no longer intended to live as the project’s ordinary local terraform.tfstate.

It is stored in the S3 backend at:

demo/terraform.tfstate

Apply and Observe the Locking Model

Run:

terraform apply

During an operation that can modify state, Terraform acquires the backend lock.

Conceptually:

Terraform
    β”‚
    β”œβ”€β”€ acquire S3 lock
    β”‚
    β”œβ”€β”€ read state
    β”‚
    β”œβ”€β”€ apply changes
    β”‚
    β”œβ”€β”€ write state
    β”‚
    └── release lock

The S3 backend’s current locking implementation uses a lock object alongside the state object. Terraform’s documentation also specifies separate permissions for the lock file, including GetObject, PutObject, and DeleteObject. ξˆ€citeξˆ‚turn0search0

Why Versioning Matters

Suppose someone accidentally deletes or damages a state object.

S3 versioning gives the bucket a history of object versions.

That changes the recovery story from:

State deleted
     β”‚
     β–Ό
Panic

to:

State changed or deleted
        β”‚
        β–Ό
S3 object version history
        β”‚
        β–Ό
Investigate and recover

HashiCorp strongly recommends S3 bucket versioning for state recovery from accidental deletion and human error.

Versioning is therefore a recovery mechanism, not a replacement for locking.

Access Control

The Terraform identity needs access to the state.

At minimum, an S3 backend commonly needs permissions to list the relevant bucket path and read/write the state object. When S3 lock files are enabled, the identity also needs permissions to read, create, and delete the lock object.

Conceptually:

Terraform identity
       β”‚
       β”œβ”€β”€ List relevant state path
       β”œβ”€β”€ Read state
       β”œβ”€β”€ Write state
       β”‚
       └── Lock file
             β”œβ”€β”€ Read
             β”œβ”€β”€ Create
             └── Delete

Do not grant broad administrative access merely because Terraform is convenient.

The principle should be:

Give the Terraform identity only the backend permissions it actually needs, plus the infrastructure permissions required by the configuration it manages.

These are separate concerns.

A CI identity might have permission to access production state but still be restricted from unrelated AWS resources.

State Isolation

The key determines where a configuration’s state lives.

For example:

backend "s3" {
  bucket       = "devstuffs-terraform-state-example"
  key          = "production/network/terraform.tfstate"
  region       = "us-east-1"
  use_lockfile = true
}

Another configuration could use:

production/application/terraform.tfstate

This gives you logical state boundaries.

S3 bucket
β”‚
β”œβ”€β”€ production/
β”‚   β”œβ”€β”€ network/
β”‚   β”‚   └── terraform.tfstate
β”‚   └── application/
β”‚       └── terraform.tfstate
β”‚
└── development/
    β”œβ”€β”€ network/
    β”‚   └── terraform.tfstate
    └── application/
        └── terraform.tfstate

This structure also helps access control and reduces blast radius.

What About DynamoDB?

You may encounter older Terraform tutorials showing:

backend "s3" {
  bucket         = "..."
  key            = "..."
  region         = "..."
  dynamodb_table = "terraform-locks"
}

This was a common AWS architecture for Terraform state locking.

It is useful to understand historically because many existing environments still use it. However, current Terraform documentation marks DynamoDB-based S3 state locking as deprecated and recommends the S3 lock-file mechanism instead.

Terraform deprecated DynamoDB state locking because S3 now provides native locking through lock files and conditional writes, eliminating the need for a separate DynamoDB-based locking mechanism.

For new configurations, prefer:

use_lockfile = true

If you’re migrating an older setup, Terraform supports configuring the old DynamoDB mechanism alongside S3 locking to help with migration:

terraform {
  backend "s3" {
    bucket       = "my-terraform-state"
    key          = "prod/terraform.tfstate"
    region       = "us-east-1"

    # New S3 locking
    use_lockfile = true

    # Existing DynamoDB locking
    dynamodb_table = "terraform-state-lock"
  }
}

Backend Migration in Practice

Suppose you already have a local state:

terraform-remote-state-demo/
β”œβ”€β”€ main.tf
└── terraform.tfstate

You add:

terraform {
  backend "s3" {
    bucket       = "devstuffs-terraform-state-example"
    key          = "demo/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
  }
}

Then run:

terraform init

Terraform recognizes that the backend changed and can offer to migrate the existing state.

Before confirming, check the destination carefully.

After migration:

terraform plan

A plan that unexpectedly wants to destroy and recreate everything is a strong warning sign.

Stop and investigate the backend configuration and state before applying.

Protecting the State Bucket

The state bucket deserves stronger protection than an ordinary temporary S3 bucket.

At minimum, consider:

  • Encryption
  • Versioning
  • Public access blocking
  • Restricted IAM access
  • Separate production and non-production state paths or buckets
  • Monitoring and audit logging appropriate to your organization
  • A recovery procedure

Also remember that encryption does not solve authorization.

Encryption
   β”‚
   └── protects data if storage is accessed improperly

IAM
   β”‚
   └── controls who can access the data

You need both.

Cleanup

The remote-state demo resource can be destroyed with:

terraform destroy

The bootstrap bucket should only be removed when you are certain no Terraform configuration still uses it.

Destroying a state bucket that contains active Terraform state can make recovery dramatically harder.

For a learning environment, clean up the demo infrastructure first, then remove the bootstrap bucket deliberately.

The Architecture You Should Remember

After this exercise, the important architecture is:

Bootstrap
   β”‚
   β”œβ”€β”€ local state
   β”‚
   └── creates
         β”‚
         β–Ό
   S3 State Bucket
         β”‚
         β”œβ”€β”€ encryption
         β”œβ”€β”€ versioning
         β”œβ”€β”€ access control
         └── lock files
                 β”‚
                 β–Ό
          Terraform Projects
                 β”‚
                 β–Ό
          Managed Infrastructure

The state bucket is now a piece of production infrastructure in its own right.

That leads naturally to the next question: how should you separate development, staging, and production state?

That is where Terraform workspaces enter the discussion.

Last updated on