This is the concept most newcomers stumble on, so read it slowly.
When OpenTofu creates a resource in AWS, it needs to remember "I created this
specific thing, and here is its real AWS ID." It stores that record in a file
called state (terraform.tfstate, which is JSON). State is the link between
your code (aws_s3_bucket.state) and the real resource in AWS
(alphaform-dev-tofu-state).
Why state matters:
- On the next run, OpenTofu compares three things - your code (desired), the state (what it believes exists), and reality (what AWS reports) - and computes the minimal set of changes. Without state it couldn't tell "create new" from "update existing."
- State can contain sensitive values (like generated passwords), so it must be stored securely.
Local vs remote state
By default, state is a file on your laptop. That's fine for one person experimenting, but bad for a team: nobody else can see it, and two people editing at once would corrupt it.
The fix is a remote backend: store the state file in a shared location. This
repo uses an S3 backend - the state lives in an S3 bucket that the whole team
(and the CI pipeline) shares. From envs/dev/backend.tf:
terraform {
backend "s3" {
bucket = var.state_bucket_name
key = "envs/dev/terraform.tfstate" # path within the bucket
region = var.region
use_lockfile = true
encrypt = true
}
}bucket/key- which S3 bucket and which path inside it.encrypt = true- encrypt the state at rest.use_lockfile = true- state locking (next).
State locking
If two people ran apply at the same moment, they could corrupt the state.
Locking prevents that: before changing state, OpenTofu acquires a lock; anyone
else who tries gets "Error acquiring the state lock" and waits.
Historically Terraform needed a separate DynamoDB table for this. This repo uses
the newer S3-native locking (use_lockfile = true), where the lock is just a
small .tflock object written into the same bucket. This is why the README states
"OpenTofu ≥ 1.10 is required - state locking is S3-native, with no DynamoDB
table." It's simpler and cheaper.
The chicken-and-egg problem (and how this repo solves it)
The remote state lives in an S3 bucket - but something has to create that bucket, and that something is OpenTofu, which wants a remote backend… which doesn't exist yet. Classic chicken-and-egg.
The solution is the bootstrap/ layer. It is the one place that deliberately
uses local state, precisely because its job is to create the remote backend
that everything else then uses. From
bootstrap/versions.tf:
# Bootstrap intentionally uses LOCAL state: it is the thing that creates the
# remote-state backend, so it cannot depend on it.So the order is: run bootstrap once (local state) to create the S3 bucket + KMS
key, then point every other layer's backend at that bucket. We dig into bootstrap
in section 9.
Backend values split: variables vs generated
A subtle but clever detail. The backend needs three things: the bucket name, the
region, and the encryption key (kms_key_id). This repo splits them:
- bucket + region come from variables (
var.state_bucket_name,var.region)- OpenTofu resolves them at
inittime. You set them interraform.tfvars.
- OpenTofu resolves them at
- kms_key_id is the only value generated by bootstrap (it's the new key's
ARN). It's written into a small file
backend.s3.tfbackendand passed at init via-backend-config. Seeenvs/dev/backend.s3.tfbackend.example.
The Makefile's backend-config target automates pulling that ARN from bootstrap's
output into each layer's backend.s3.tfbackend file. You don't have to do it by
hand.