Skip to content

State and Backends

Rod Christiansen edited this page Oct 8, 2026 · 2 revisions

State and Backends

How this module expects Terraform state to be stored, and what the bootstrap/ module is for.

The backend declaration

versions.tf declares an S3 backend with an empty body:

terraform {
  backend "s3" {}
}

No bucket, key, region, or lock table is hardcoded — deliberately, since this module is published and a literal would point every user's state at one account. You must supply the configuration at init time, either with -backend-config=backend.hcl or with repeated -backend-config=key=value flags.

The four keys the backend needs, plus encrypt, are shown in backend.hcl.example. backend.hcl itself is gitignored.

Bootstrap

State storage is a chicken-and-egg problem: you cannot keep the state for the state bucket in the state bucket. bootstrap/ is a small, separate root module that solves it. It uses local state, creates exactly two things, and is meant to be run once per account:

  • An S3 bucket named <project_name>-terraform-state, versioned, SSE-KMS encrypted, with all public access blocked and prevent_destroy set.
  • A DynamoDB table named <project_name>-terraform-lock, pay-per-request, with a LockID string hash key.
cd bootstrap
terraform init
terraform apply

Its three outputs — state_bucket, lock_table, region — are exactly what backend.hcl needs.

The bootstrap module's own state files are gitignored (bootstrap/terraform.tfstate*). If you plan to run bootstrap from more than one machine, back that file up or move it into the bucket it just created.

Naming mismatch to watch for

Three files in the repo disagree on the lock table name. bootstrap/main.tf creates <project_name>-terraform-lock, backend.hcl.example uses reportmate-terraform-lock, but the quick-start snippet in README.md writes reportmate-terraform-locks — plural. Copy the README snippet verbatim and terraform init will fail to acquire a lock. Take the value from the bootstrap output, not the README.

Locking

DynamoDB-based locking means concurrent applies from CI and a laptop will block rather than corrupt each other. Nothing in the module sets use_lockfile, so the DynamoDB table is the only lock: leave dynamodb_table out of backend.hcl and state is not locked at all.

Multiple environments

The module has no workspace logic. To run more than one environment, use a different key in the backend config (the example uses prod/terraform.tfstate) and set environment to match. Bear in mind two things that are not environment-scoped and will collide inside one account:

  • ECR repository names use project_name only, so two environments in the same account share the same <project>-api, <project>-frontend, <project>-maintenance, and <project>-demo-loop repositories.
  • The Cognito hosted-UI domain and the S3 bucket names are globally unique; they do include the environment, so they will not collide across environments, but they will collide with any other AWS account that picked the same project_name and environment.

Changing project_name per environment sidesteps all of this at the cost of uglier names.

CI

.github/workflows/terraform.yml runs plan on pull requests and apply on pushes to main, using OIDC (id-token: write) to assume secrets.AWS_ROLE_ARN and reading TF_VAR_db_password, TF_VAR_api_internal_secret, and TF_VAR_client_passphrase from repository secrets. It also posts the plan as a PR comment and gates apply on a production GitHub environment.

The workflow is not usable as-is in this repo: both its path filter and its TF_DIR point at infrastructure/aws, a directory that exists in the monorepo this module was extracted from but not here. As written the paths filter never matches, so the workflow never triggers; if you make it trigger, every step fails on a missing working directory. Set TF_DIR to . and the paths filter to something that matches this repo before relying on it. It also does not pass TF_VAR_public_api_url, which is a required variable.

Destroying

terraform destroy will not complete without manual steps. The RDS instance carries both deletion_protection = true and a prevent_destroy lifecycle block, and the bootstrap state bucket has prevent_destroy too. Removing them requires editing the code, and the database is configured to take a final snapshot named <prefix>-final-snapshot — a name that will collide if you tear down and rebuild the same environment twice.

Clone this wiki locally