Skip to content

Terraform State & Multi-Account Setup

How Terraform state is stored and how Terraform reaches each of the three AWS accounts (dev, staging, prod).

State backend

All environments share one S3 bucket and one DynamoDB lock table. Each environment keeps its state under a distinct key.

Resource Name Region
State bucket construo-terraform-state eu-west-2
Lock table terraform-state-lock eu-west-2
Encryption key alias/construo-terraform-state (KMS CMK, rotation on) eu-west-2
construo-terraform-state/
  environments/dev/terraform.tfstate
  environments/staging/terraform.tfstate
  environments/prod/terraform.tfstate

The bucket is versioned, SSE-KMS encrypted (bucket key on), has all public access blocked, and a bucket policy denying non-TLS access. The lock table uses PAY_PER_REQUEST billing with a LockID hash key.

S3 bucket names are globally unique, so this bucket lives in one account — the state account. In practice that is whichever account you bootstrap with; a dedicated management account is cleanest, but using the dev account is acceptable for a small team.

Bootstrapping the backend

The backend cannot manage itself — it must exist before any terraform init. This is the one place raw aws CLI is sanctioned for creating infrastructure.

cd infra/terraform/bootstrap
aws sts get-caller-identity   # confirm you're in the state account
./bootstrap.sh --dry-run      # prints every command, changes nothing
./bootstrap.sh                # idempotent — re-running is a no-op

The script creates the KMS key, the S3 bucket, and the DynamoDB table. The equivalent Terraform HCL is committed alongside it for the record and for terraform import; see infra/terraform/bootstrap/README.md.

Account-per-env and assume-role

Per CLAUDE.md, each environment is a separate AWS account, not just a separate VPC:

Environment Data Account
dev synthetic only dev account
staging anonymised copy of prod staging account
prod real tenant data prod account

State lives in the state account; the resources for each environment live in that environment's account. Terraform bridges the two like this:

  • The S3 backend uses the runner's base credentials to read/write state in the state account.
  • The AWS provider assumes a per-account deploy role to create the actual resources, controlled by the terraform_role_arn variable in each environment.
provider "aws" {
  region = var.region

  dynamic "assume_role" {
    for_each = var.terraform_role_arn == "" ? [] : [1]
    content {
      role_arn     = var.terraform_role_arn
      session_name = "terraform-<env>"
    }
  }
}

When terraform_role_arn is empty (the dev default), Terraform uses ambient credentials directly — this keeps local dev working without any role assumption. In CI, the workflow authenticates via GitHub OIDC (no long-lived keys) and sets terraform_role_arn to the target account's deploy role (wired in ticket F5).

Roles you must create (human/IAM bootstrap)

These are created once, outside the agent's scope, before staging/prod applies:

  1. State-account access for each CI principal: read/write on construo-terraform-state (and its keys/prefixes), kms:Decrypt/Encrypt/ GenerateDataKey on the state CMK, and dynamodb:*Item on terraform-state-lock.
  2. Per-account deploy role in each of dev/staging/prod (construo-terraform-<env>) with a trust policy allowing the CI OIDC principal (and/or your operator role) to assume it, and the permissions to manage the services in Section F.

The agent must never widen its own permissions; these roles are provisioned by a human.

Running Terraform

# dev — local credentials, no role assumption needed
cd infra/terraform/environments/dev
terraform init
terraform plan

# staging / prod — set the deploy role (or let CI set it via OIDC)
cd infra/terraform/environments/staging
terraform init
terraform plan -var 'terraform_role_arn=arn:aws:iam::<staging-acct>:role/construo-terraform-staging'

Never run terraform apply against staging or prod by hand — applies are gated behind human approval in CI (F5). dev apply may be delegated to the agent only when explicitly authorised for the session.