Skip to main content
RunBook Academy

TerraformXV · Environment Architecture and State BoundariesProduction Terraform

Multi-Account and Multi-Project Production

Intermediate⏱ ~12 minbash

What you'll learn

  • Choose an account boundary strategy (per-env, per-region, per business unit)
  • Configure Terraform to assume roles across accounts
  • Describe how AWS Organizations and Control Tower fit into Terraform estates
  • Identify the per-account state backend pattern

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

The directory-per-environment layout puts each environment in its own state. The next boundary up is the cloud account: one AWS account, GCP project, or Azure subscription per environment. The lesson teaches when this is the right call, how Terraform crosses the account boundary, and how AWS Organizations and Control Tower fit into the picture.

The account as a blast-radius boundary

A blast-radius boundary is a barrier that an apply cannot cross. The state is one such boundary. The directory is another. The cloud account is the largest blast-radius boundary that Terraform alone cannot cross - because the credentials are different.

Consider two operations on the same Terraform configuration:

Operation A:  dev environment, dev AWS account
              IAM role: dev/terraform-execution
              State:    s3://state/dev/

Operation B:  production, production AWS account
              IAM role: prod/terraform-execution
              State:    s3://state/prod/

Operation A cannot affect Operation B because:

  • The IAM role for dev has no permissions in the production account.
  • The state bucket for dev cannot be read by the production IAM role.
  • The provider is configured with a different assume_role target.

A misconfigured apply in dev cannot delete a production database because the credentials do not exist in the production account. The boundary is enforced by AWS itself.

Three common account strategies

The strategies are not mutually exclusive. A team of size uses one for the top-level cut and another for the next-level cut.

One account per environment. The most common production layout: separate AWS accounts for prod, staging, dev, and sandbox. Each account is a blast-radius boundary. A misconfigured apply in dev cannot affect prod.

One account per region. A team that operates in multiple regions may put each region in its own account. The benefit is isolation of regional blast radius (e.g. an IAM policy mistake in us-east-1 cannot affect eu-west-1). The cost is that cross-region resources require cross-account roles.

One account per business unit. A larger organisation may give each business unit its own account (or set of accounts). The benefit is autonomy: each unit operates independently. The cost is governance: a central platform team must enforce common baselines.

A real production estate is usually a combination. The “production” account is one account per region per service per environment, organised by AWS Organizations.

AWS Organizations and Control Tower

AWS Organizations is the AWS-native primitive for grouping accounts. An organisation has a root account (the management account) and zero or more member accounts. The member accounts inherit service control policies (SCPs) from the organisation unit (OU) hierarchy.

A typical production layout:

root
├── Security OU
│   └── log-archive (account)
├── Infrastructure OU
│   ├── shared-services (account)
│   └── networking (account)
└── Workloads OU
    ├── prod OU
    │   ├── prod-use1 (account, region us-east-1)
    │   ├── prod-euw1 (account, region eu-west-1)
    │   └── prod-apne1 (account, region ap-northeast-1)
    ├── staging OU
    │   └── staging-use1 (account)
    └── dev OU
        └── dev-use1 (account)

The OUs are operational groupings. SCPs are attached to OUs and apply to every member account. An SCP can deny the use of particular services in the dev OU, or deny ec2:TerminateInstances in the prod OU.

AWS Control Tower automates the bootstrapping of this layout. Control Tower provisions the organisation, the OUs, the security accounts (log archive, audit), and the baseline SCPs. The Terraform estate then operates inside the Control Tower guardrails.

Provider aliases across accounts

Terraform crosses the account boundary with the AWS provider’s assume_role block. Each account has its own provider configuration:

# infra/envs/prod/provider.tf
provider "aws" {
  region = "us-east-1"

  assume_role {
    role_arn = "arn:aws:iam::111111111111:role/terraform-execution"
  }
}

provider "aws" {
  alias  = "staging"
  region = "us-east-1"

  assume_role {
    role_arn = "arn:aws:iam::222222222222:role/terraform-execution"
  }
}

provider "aws" {
  alias  = "log_archive"
  region = "us-east-1"

  assume_role {
    role_arn = "arn:aws:iam::333333333333:role/terraform-execution"
  }
}

Resources declare which provider they use:

resource "aws_s3_bucket" "prod_data" {
  # default provider: production
  bucket = "prod-data"
}

data "aws_s3_bucket" "shared_logs" {
  # log-archive account
  provider = aws.log_archive
  bucket   = "shared-logs"
}

The provider alias is the explicit declaration of which account the resource belongs to. Without the alias, all resources go to the default provider (production, in this case).

Cross-account data sources

A common pattern is to read outputs from another account:

# infra/envs/prod/data.tf
data "terraform_remote_state" "network_prod_use1" {
  backend = "s3"
  config = {
    bucket = "mycompany-terraform-state"
    key    = "network/prod-use1/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_instance" "web" {
  ami           = "ami-0e1bed4f"
  subnet_id     = data.terraform_remote_state.network_prod_use1.outputs.public_subnet_id
  instance_type = "m5.large"
}

The terraform_remote_state data source reads the network state. The bucket is the same across accounts; the key distinguishes the environment. The IAM role for production must have permission to read the relevant state keys.

The pattern is:

  • Network lives in prod-use1. The state is at s3://state/network/prod-use1/terraform.tfstate.
  • Compute lives in prod-use1. The state is at s3://state/compute/prod-use1/terraform.tfstate.
  • The compute configuration reads the network state via terraform_remote_state.

The cross-account data source is the contract. The producer publishes outputs; the consumer reads them. Renaming an output in the producer is a contract change.

The per-account state backend

A production estate stores state in a separate account (often called the state or shared-services account). The state bucket is centralised; the state keys are per-account:

s3://mycompany-terraform-state/
├── network/
│   ├── prod-use1/terraform.tfstate
│   ├── prod-euw1/terraform.tfstate
│   └── staging-use1/terraform.tfstate
├── compute/
│   ├── prod-use1/terraform.tfstate
│   └── staging-use1/terraform.tfstate
└── platform/
    └── terraform.tfstate

The state account is governed by the same SCPs as everything else: only the terraform-execution role in the relevant member account can read or write the relevant state keys.

A separate DynamoDB table per environment or per account is the recommended lock-table pattern:

terraform-locks-prod-use1
terraform-locks-prod-euw1
terraform-locks-staging-use1

Per-account lock tables prevent a misconfigured apply in staging from blocking a legitimate apply in production.

When the account boundary is wrong

The account boundary is not free. Each account has:

  • An SCP overhead.
  • A billing boundary (consolidated billing helps).
  • An IAM baseline that must be applied to every account.
  • A separate state bucket or state key.

A team of two engineers operating a single Lambda function does not need four accounts. The blast radius of a misconfigured apply is one Lambda function. The cost of the account boundary is real and the benefit is small.

The right cut is: account boundary where the blast radius matters. Account boundary for production. No account boundary for dev sandboxes.

What comes next

The next lesson is state boundaries as the unit of failure: the design of state splits within a single account, and the discipline that prevents cross-state references from becoming cross-state incidents.

Verification

  • aws organizations describe-organization returns the organisation root and the member accounts.
  • aws sts get-caller-identity from the Terraform execution context returns the account ID of the working environment, not the management account.
  • aws s3 ls s3://mycompany-terraform-state/network/ shows one state key per environment per region.
  • terraform plan in the production directory shows only resources in the production account; cross-account data sources are read-only.
  • The SCP attached to the prod OU denies ec2:TerminateInstances for resources tagged with Environment=prod; a terraform apply that would terminate a production instance is rejected by IAM before Terraform proposes the change.

Knowledge check · 6 questions

  1. Q1. What is the primary operational benefit of one AWS account per environment?

  2. Q2. What is the role of AWS Control Tower in a Terraform estate?

  3. Q3. Why should Terraform not run in the AWS Organizations management account?

  4. Q4. A separate DynamoDB lock table per account or environment is the recommended pattern.

  5. Q5. Which of the following are valid reasons to use one AWS account per region? (Select all that apply.)

  6. Q6. A team has dev, staging, and production in the same AWS account. A destructive change targets dev, but the IAM role has administrator access in the account. The apply accidentally destroys a production resource. What is the systemic fix?

Passing score: 75%. Answers are checked in this browser.