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_arnvariable 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:
- State-account access for each CI principal: read/write on
construo-terraform-state(and its keys/prefixes),kms:Decrypt/Encrypt/GenerateDataKeyon the state CMK, anddynamodb:*Itemonterraform-state-lock. - 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.