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 InfrastructureThe 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 = trueThe 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 objectsThe state object and lock object have different purposes.
For a key such as:
prod/network/terraform.tfstatethe S3 backend can use a corresponding lock file:
prod/network/terraform.tfstate.tflockThe 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.tfThis 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-1The 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-bootstrapCreate 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.tfCreate the State Bucket
Choose a globally unique bucket name.
For example:
terraform initThen:
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 bucketDo 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-demoCreate 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.tfChoose a globally unique name for the example bucket if necessary.
Now initialize:
terraform initTerraform connects to the remote backend.
You can verify the configuration with:
terraform planNotice 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.tfstateApply and Observe the Locking Model
Run:
terraform applyDuring an operation that can modify state, Terraform acquires the backend lock.
Conceptually:
Terraform
β
βββ acquire S3 lock
β
βββ read state
β
βββ apply changes
β
βββ write state
β
βββ release lockThe 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
β
βΌ
Panicto:
State changed or deleted
β
βΌ
S3 object version history
β
βΌ
Investigate and recoverHashiCorp 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
βββ DeleteDo 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.tfstateThis gives you logical state boundaries.
S3 bucket
β
βββ production/
β βββ network/
β β βββ terraform.tfstate
β βββ application/
β βββ terraform.tfstate
β
βββ development/
βββ network/
β βββ terraform.tfstate
βββ application/
βββ terraform.tfstateThis 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 = trueIf 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.tfstateYou add:
terraform {
backend "s3" {
bucket = "devstuffs-terraform-state-example"
key = "demo/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}Then run:
terraform initTerraform recognizes that the backend changed and can offer to migrate the existing state.
Before confirming, check the destination carefully.
After migration:
terraform planA 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 dataYou need both.
Cleanup
The remote-state demo resource can be destroyed with:
terraform destroyThe 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 InfrastructureThe 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.