AWS with Terraform Tutorial: Terraform Backends (19)
How to configure a Terraform backend in AWS S3 #
Using the Terraform and OpenTofu s3 backend to keep the state in a versioned, encrypted bucket that a whole team (and a CI/CD pipeline) can share safely.
Welcome to our tutorial series about Terraform or OpenTofu on AWS. Terraform remembers what it created in a state file, terraform.tfstate, which by default is saved next to the code. That is fine for learning, but it becomes a problem as soon as the infrastructure is important:
- if the file is lost, Terraform no longer knows its resources,
- two people (or a person and a pipeline) running
tofu applyat the same time can corrupt it, - the state contains sensitive data (database passwords, keys) in plain text and must not be committed to Git.
A backend defines where the state is stored. This section moves it to Amazon S3.
Prerequisites #
Read the previous sections of the tutorial, listed in the series index at the end of this page. You need an AWS profile (ditwl_infradmin, see Terraform AWS Provider) with permissions to create an S3 bucket.
Terraform backends #
The backend is configured in the terraform block. The most used ones are:
| Backend | State stored in | Locking |
|---|---|---|
local (default) |
A file in the project directory | Local file lock |
s3 |
An Amazon S3 bucket | S3 lock file (or DynamoDB) |
gcs / azurerm |
Google Cloud Storage / Azure Blob Storage | Yes |
http, pg, kubernetes |
A REST endpoint (for example GitLab), PostgreSQL, a Kubernetes secret | Depends on the backend |
cloud |
HCP Terraform (Terraform Cloud) | Yes |
The S3 backend is the most common choice on AWS: it is cheap, durable (99.999999999%), supports versioning and encryption, and uses the same IAM permissions as the rest of the infrastructure.
Step 1: create the bucket #
There is a chicken-and-egg problem: the bucket that stores the state must exist before Terraform can use it, so it is created by a separate, small project (backend-bootstrap/) that keeps its own state locally. It is run once.
terraform {
required_version = "> 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
profile = "ditwl_infradmin"
}
data "aws_caller_identity" "current" {}
locals {
# Bucket names are global: the account number makes it unique
bucket = "ditwl-tfstate-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket" "tfstate" {
bucket = local.bucket
# Deleting this bucket would delete the state of all the infrastructure
lifecycle {
prevent_destroy = true
}
tags = {
Name = local.bucket
}
}
# Keep every version of the state: it allows going back after a mistake
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
# Encrypt the state at rest
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# The state is never public
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
}
# Delete old versions after 90 days
resource "aws_s3_bucket_lifecycle_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
id = "expire-old-versions"
status = "Enabled"
filter {}
noncurrent_version_expiration {
noncurrent_days = 90
}
}
}
# Only encrypted connections (HTTPS)
data "aws_iam_policy_document" "tfstate-tls" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
actions = ["s3:*"]
resources = [aws_s3_bucket.tfstate.arn, "${aws_s3_bucket.tfstate.arn}/*"]
principals {
type = "*"
identifiers = ["*"]
}
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
policy = data.aws_iam_policy_document.tfstate-tls.json
depends_on = [aws_s3_bucket_public_access_block.tfstate]
}
output "bucket" {
value = aws_s3_bucket.tfstate.bucket
}$ cd backend-bootstrap
$ tofu init
$ tofu apply
...
Outputs:
bucket = "ditwl-tfstate-123456789012"
Step 2: configure the backend #
In the main project add the backend block (inside the same terraform block as required_providers):
terraform {
required_version = "> 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "ditwl-tfstate-123456789012"
key = "aws-tutorial/pro/terraform.tfstate"
region = "us-east-1"
profile = "ditwl_infradmin"
encrypt = true
use_lockfile = true
}
}keyis the path of the state file inside the bucket. Use one key per project and environment (aws-tutorial/pro/...,aws-tutorial/dev/...) to keep the states small and independent.encrypt = trueasks S3 to encrypt the object.use_lockfile = trueenables native state locking: the backend creates aterraform.tfstate.tflockobject next to the state while a command that changes it runs, and S3 conditional writes guarantee that only one process gets it. No other service is needed.
Step 3: migrate the state #
$ tofu init -migrate-state
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "s3" backend. ...
Enter a value: yes
Successfully configured the backend "s3"!
The state is now in S3. Verify it and keep the local copy until you are sure that everything works, then delete it (and make sure that *.tfstate* is in .gitignore):
$ aws s3 ls s3://ditwl-tfstate-123456789012/aws-tutorial/pro/ --profile ditwl_infradmin
$ tofu plan
$ tofu state list
Test the state locking #
Run tofu apply in two terminals at the same time. The second one waits and fails with a message like this one, which shows who holds the lock:
Error: Error acquiring the state lock
Lock Info:
ID: 0d1b2c3d-...
Operation: OperationTypeApply
Who: jruiz@laptop
Created: 2026-10-05 10:35:12 UTC
If a process dies and leaves a stale lock, release it with tofu force-unlock <ID>, only after being sure that nobody is running.
Who can access the state #
The state may contain secrets, so access is controlled with IAM. This policy lets a user or pipeline use only the state of this project:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::ditwl-tfstate-123456789012",
"Condition": { "StringLike": { "s3:prefix": ["aws-tutorial/pro/*"] } }
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::ditwl-tfstate-123456789012/aws-tutorial/pro/*"
}
]
}
The lock file is stored under the same key prefix, so the policy covers it. Read-only users (for example, a pipeline that only runs tofu plan -lock=false) need just s3:GetObject and s3:ListBucket. To also encrypt the contents of the state before it is sent to S3, read How to Encrypt Terraform State with OpenTofu.
Locking with DynamoDB #
For versions without native locking, create a table with a LockID key and point the backend to it:
resource "aws_dynamodb_table" "tflock" {
name = "ditwl-tflock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
} backend "s3" {
# ... the same arguments ...
dynamodb_table = "ditwl-tflock"
}The IAM policy of the users also needs dynamodb:GetItem, dynamodb:PutItem and dynamodb:DeleteItem on the table.
Partial configuration #
The backend block cannot use Terraform variables (OpenTofu allows variables and locals in recent versions). To avoid repeating values between environments, or to keep them out of the repository, leave them out of the block and pass them when initializing:
bucket = "ditwl-tfstate-123456789012"
key = "aws-tutorial/dev/terraform.tfstate"
region = "us-east-1"$ tofu init -backend-config=backend.hcl
AWS S3 Cost #
A state file is a few kilobytes: storage, versions and requests cost a few cents a month at most. It is the cheapest insurance for your infrastructure.
Common Questions About Terraform Backends #
Can I store the state in Git? #
No. It contains secrets in plain text, has no locking and merging two versions of a state file is not possible.
What if I change the key or the bucket? #
Terraform sees an empty state and would try to create everything again. Change the configuration and run tofu init -migrate-state so that the existing state is copied to the new location.
Should I use workspaces for the environments? #
Workspaces keep several states for the same code and backend. Many teams prefer one directory (or one key) per environment because it is more explicit and avoids applying to the wrong one.
How can another project read the outputs of this one? #
With the terraform_remote_state data source, which only needs read access to the state, or better with a data source of the real resource (for example aws_vpc), which does not depend on the other project's state.
Next Steps #
The state is shared and protected. Continue with Terraform Tools to validate, lint and document the code.