Multi-Account, Multi-Region, and Environment Design

Multi-account, multi-region, and environment design is the Terraform discipline of deciding where configuration runs, which AWS identity it uses, which remote state owns each object, and how changes move from dev to production. The outcome is a layout where a developer can plan dev without production credentials, a platform engineer can add a region without rewriting modules, and an incident responder can identify which state file owns a broken resource.

In this AWS-focused section of the Terraform course, the topic connects provider configuration, module boundaries, remote state, and automation. Terraform sees provider instances, resource addresses, dependency edges, input values, and state snapshots. Good design translates boundaries such as dev, stage, prod, primary region, disaster recovery region, network account, and workload account into explicit Terraform mechanics.

Purpose and Outcome

A single AWS account and region can work for experiments, but it is a weak boundary for teams, blast radius, compliance, and recovery. Separate accounts let IAM, billing, quotas, and guardrails enforce isolation. Separate regions support resilience and latency goals. Separate environments give changes a promotion path before they affect users.

Terraform design must decide whether those separations become separate root modules, workspaces, backend keys, pipelines, provider aliases, or a combination. The key invariant is that each state file has one clear ownership boundary. A state file for network/prod/us-east-1 should not also own dev IAM roles or a staging cluster. Clear ownership means smaller plans, shorter locks, easier review, and better containment when a change is wrong.

How Terraform Represents the Boundary

Terraform uses a provider configuration to speak to a remote API. With AWS, the provider includes a region and may include an assumed role. The default provider is addressed as aws; named instances use aliases such as aws.prod_us_east_1. Modules do not automatically know which account or region they should use. The caller passes provider instances into child modules through the providers map.

State is the second boundary. State binds Terraform resource addresses, such as module.prod_network.aws_vpc.this, to real AWS object identifiers. If one state file owns too many accounts or regions, every plan needs broad credentials and a larger refresh. If state is split too aggressively, cross-stack dependencies multiply. A practical split is usually by capability, environment, and region: for example network/prod/us-east-1, eks/prod/us-east-1, and network/dev/us-east-1.

Workspaces can represent multiple state instances for one root module, but they are not a full environment strategy. They do not automatically change IAM permissions, backend bucket policies, review rules, or account guardrails. Workspaces fit many similar ephemeral stacks. For long-lived AWS environments, explicit backend keys and pipeline variables are usually easier to audit.

Configuration Anatomy

The root module declares provider requirements, backend configuration, input variables, provider instances, and module calls. Reusable modules should describe infrastructure shape without hard-coding account IDs, credentials, or region-specific assumptions. The pipeline supplies the selected environment, account role ARN, region, and backend key. The backend key decides where state lives; the provider decides where API calls go.

The following example shows two AWS accounts in one root module. It teaches provider aliasing, though many teams would split dev and prod into separate jobs and state files. The expected behavior is that module.dev_network creates its VPC using the dev role, while module.prod_network uses the prod role. If the prod role is unavailable, the prod module fails during refresh, plan, or apply.

terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
    }
  }
}

variable "account_roles" {
  type = object({
    dev  = string
    prod = string
  })
}

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

  assume_role {
    role_arn = var.account_roles.dev
  }

  default_tags {
    tags = {
      Environment = "dev"
      ManagedBy   = "Terraform"
    }
  }
}

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

  assume_role {
    role_arn = var.account_roles.prod
  }

  default_tags {
    tags = {
      Environment = "prod"
      ManagedBy   = "Terraform"
    }
  }
}

module "dev_network" {
  source = "./modules/network"

  providers = {
    aws = aws.dev_us_east_1
  }

  name       = "app-dev"
  cidr_block = "10.10.0.0/16"
}

module "prod_network" {
  source = "./modules/network"

  providers = {
    aws = aws.prod_us_east_1
  }

  name       = "app-prod"
  cidr_block = "10.20.0.0/16"
}

The child module can stay generic. Inside ./modules/network, resources use the unaliased aws provider, and the caller decides what that means. The trade-off is that one plan touches two accounts. That may be acceptable for shared-network orchestration, but it is often too much blast radius for normal application delivery.

Example 1: State Key per Environment and Region

The first progressive step is isolating state. A backend key such as network/prod/us-east-1.tfstate says exactly which infrastructure slice this root owns. A plan for this root should not show resources from network/dev/us-east-1 or network/prod/us-west-2. After terraform init, Terraform reads and locks this one state location for plans and applies.

terraform {
  backend "s3" {
    bucket         = "example-platform-terraform-state"
    key            = "network/prod/us-east-1.tfstate"
    region         = "us-east-1"
    dynamodb_table = "example-platform-terraform-locks"
    encrypt        = true
  }
}

locals {
  environment = "prod"
  region      = "us-east-1"
}

Use separate keys, and often separate CI jobs, for long-lived environments. The backend bucket and lock table should live in a management or tooling account with tight policies. Dev operators should not be able to read production state because state can contain sensitive attributes and resource identifiers. If a backend key is wrong, stop, reinitialize with the intended backend configuration, and confirm the plan uses the expected state lineage.

Example 2: Region Expansion with Explicit Providers

The second step is adding a region. Terraform cannot dynamically choose provider aliases with for_each, so regional expansion often uses one module block per supported region or separate generated roots. The example keeps selection explicit. If enabled_regions contains both regions, Terraform plans two module instances. If it contains only us-east-1, network_usw2 has count zero and creates nothing.

variable "enabled_regions" {
  type    = set(string)
  default = ["us-east-1", "us-west-2"]
}

locals {
  regional_cidrs = {
    us-east-1 = "10.30.0.0/16"
    us-west-2 = "10.31.0.0/16"
  }
}

provider "aws" {
  alias  = "use1"
  region = "us-east-1"
}

provider "aws" {
  alias  = "usw2"
  region = "us-west-2"
}

module "network_use1" {
  count  = contains(var.enabled_regions, "us-east-1") ? 1 : 0
  source = "./modules/network"

  providers = {
    aws = aws.use1
  }

  name       = "prod-use1"
  cidr_block = local.regional_cidrs["us-east-1"]
}

module "network_usw2" {
  count  = contains(var.enabled_regions, "us-west-2") ? 1 : 0
  source = "./modules/network"

  providers = {
    aws = aws.usw2
  }

  name       = "prod-usw2"
  cidr_block = local.regional_cidrs["us-west-2"]
}

The expected behavior is precise: network_use1 uses aws.use1 and 10.30.0.0/16; network_usw2 uses aws.usw2 and 10.31.0.0/16. This avoids a hidden loop that deploys to every region in a list. The cost is repetition, which is often acceptable for regional foundations because regions may need different CIDRs, availability zone choices, transit gateway attachments, or failover roles.

Example 3: Environment Shape and Tests

The third step is describing how environments differ without forking the module. A good module exposes intentional variation as inputs and validates those inputs. The example gives prod a larger deployment shape and rejects invalid environment names. With environment = "prod" and cidr_block = "10.42.0.0/16", the output is deterministically { environment = "prod", size = "large", cidr = "10.42.0.0/16" } in Terraform object notation.

variable "environment" {
  type = string

  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment must be dev, stage, or prod."
  }
}

variable "cidr_block" {
  type = string

  validation {
    condition     = can(cidrnetmask(var.cidr_block))
    error_message = "cidr_block must be a valid IPv4 CIDR block."
  }
}

locals {
  size_by_environment = {
    dev   = "small"
    stage = "medium"
    prod  = "large"
  }
}

output "deployment_shape" {
  value = {
    environment = var.environment
    size        = local.size_by_environment[var.environment]
    cidr        = var.cidr_block
  }
}

Terraform tests can lock this behavior down. The first run plans successfully and asserts that prod maps to the large shape. The second supplies production, which should fail at variable validation. These tests do not replace AWS integration tests, but they catch naming and sizing mistakes before a pipeline reaches cloud credentials.

run "prod_shape_is_large" {
  command = plan

  variables {
    environment = "prod"
    cidr_block  = "10.42.0.0/16"
  }

  assert {
    condition     = output.deployment_shape.size == "large"
    error_message = "prod must use the large deployment shape."
  }
}

run "invalid_environment_is_rejected" {
  command = plan

  variables {
    environment = "production"
    cidr_block  = "10.42.0.0/16"
  }

  expect_failures = [var.environment]
}

Design Choices and Trade-offs

The most important choice is the state boundary. Splitting by environment only is simple, but a production state file can become huge if it owns global IAM, networking, databases, clusters, and regional services. Splitting by component and region keeps plans focused, but requires explicit data sharing. Prefer typed outputs consumed through automation or data sources with narrow permissions.

A second choice is account orchestration. One root module can configure several providers and connect resources across accounts, which is useful for centralized DNS, shared transit gateways, or cross-account replication. The downside is credential breadth. Separate root modules reduce credential scope and are easier to delegate, but cross-account handshakes may need staged applies.

A third choice is environment promotion. Copying dev variables into prod by hand invites drift. Better options include one module version promoted through dev, stage, and prod, environment-specific variable files reviewed in source control, and pipelines that apply the same saved plan after approval.

Failure Modes and Troubleshooting

Wrong account in the plan. The symptom is dev names in a prod plan or AWS access denied for an unexpected account. The cause is often a provider alias passed to the wrong module or local credentials overriding the intended role. Diagnose with terraform providers, inspect module providers maps, and run aws sts get-caller-identity in the same execution context. Correct the provider mapping and make the pipeline supply role ARNs explicitly.

Wrong region or missing regional API. The symptom is resources appearing in us-east-1 when us-west-2 was expected, or data sources returning empty results. The cause is usually a default provider leaking into a module or data source. Diagnose by searching for module blocks without provider mappings and checking provider addresses in the plan. Correct it by passing aliases into every regional module.

State lock contention. The symptom is a pipeline waiting on or failing to acquire a lock. The cause may be an active apply, an interrupted run, or too many unrelated resources sharing one state file. Diagnose the lock holder and CI history. If the holder is abandoned, use the backend’s documented unlock process only after confirming no apply is active. Correct recurring contention by splitting state or serializing dependent jobs.

Cross-state dependency drift. The symptom is an EKS stack still using an old subnet ID after the network stack changed. The cause is a loose dependency between states or a pipeline that does not refresh consumers after producer changes. Diagnose by comparing producer outputs with the consumer plan. Correct it by publishing stable outputs, triggering dependent plans, and migrating consumers before old resources disappear.

Security, Reliability, and Performance

Account design is a security control only if Terraform credentials respect it. Use distinct roles for each account and environment, short-lived credentials, and backend access policies separate from AWS resource permissions. Production state should have stronger read controls than dev state because it may reveal ARNs, endpoints, generated names, and sensitive attributes.

Reliability improves when regional dependencies are intentional. A workload deployed in two regions can still fail if both regions depend on one state file, one runner, or one globally configured provider. Smaller states also improve performance because they reduce refresh time and lock duration. Large monolithic states make every regional or environmental change refresh unrelated infrastructure.

Hands-on Lab: Build a Safe Layout

Prerequisites: Terraform CLI, AWS CLI, access to at least one sandbox AWS account, an S3 backend bucket and lock table if using remote state, and permission to assume the roles used by provider blocks. Without AWS access, run only terraform init -backend=false, terraform validate, and terraform test against the validation example.

  1. Create modules/network, live/dev/us-east-1/network, and live/prod/us-east-1/network.
  2. Put reusable VPC or placeholder network resources in modules/network. Keep provider blocks out of the child module.
  3. In each live directory, configure one backend key containing component, environment, and region.
  4. Add one AWS provider configured with the selected region and environment role ARN.
  5. Call the network module and pass the provider explicitly through the providers map.
  6. Run terraform fmt, terraform init, and terraform validate in the dev root.
  7. Run terraform plan -out=tfplan and confirm the plan mentions only the dev account, dev names, and selected region.
  8. Repeat the plan in the prod root, but do not apply unless this is an approved sandbox.

Verify with both Terraform and AWS evidence. Check that terraform state list in dev contains only dev network addresses. Use aws sts get-caller-identity with the same credentials to confirm the account. If you applied resources, query one resource by tag and verify Environment and ManagedBy. Cleanup from the same root with terraform destroy. Never delete the backend state object by hand as a cleanup shortcut.

Assessment Exercises

  1. You inherit one state file that manages dev and prod VPCs in two regions. Propose a split plan that minimizes downtime and explain how you would move state addresses safely.
  2. A module creates resources in the wrong AWS account even though the root has the right provider aliases. Which files and commands would you inspect first, and why?
  3. Design backend keys for network, eks, and dns across dev and prod. Identify which stacks need cross-state outputs.
  4. Explain when a workspace is acceptable for environments and when separate root modules are clearer.
  5. Write one validation rule or Terraform test that prevents prod from using a development-sized configuration.

Summary

Multi-account, multi-region, and environment design turns organizational boundaries into Terraform mechanics: provider instances for identity and region, backend keys for ownership, modules for reusable shape, and pipelines for promotion. Strong designs keep state narrow, credentials scoped, provider aliases explicit, and cross-stack dependencies visible, making plans easier to review and failures easier to diagnose.