State: OpenTofu's Memory

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:

HCL
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:

HCL
# 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 init time. You set them in terraform.tfvars.
  • 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.tfbackend and passed at init via -backend-config. See envs/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.

Adesh Tamrakar
SOFTWARE ENGINEER · VAULT

Notes, insights and random discoveries from a working engineer's vault - written for future me, published for you.