Skip to content

InsightsInfrastructure as Code

Managing many cloud accounts with Terraform

How to keep state, credentials and modules organized when Terraform manages dozens of AWS accounts, and how to plan and apply across all of them from CI.

Published June 9, 20265 min read

A multi-account cloud setup is the right default. Separate accounts give each team and environment a hard boundary for permissions, quotas and blast radius. They also multiply the Terraform problem. Ten accounts become fifty, each needs the same baseline of logging, networking and guardrails, and the one engineer who knows how to run a plan against all of them becomes a bottleneck.

The patterns below keep that manageable: one state per account and component, one way to get credentials, shared modules with versions, and a pipeline that plans every account without anyone switching profiles on a laptop.

One state per account, and per component

Do not put several accounts in one state file. A single state that spans accounts means a single plan touches all of them, a single lock blocks everyone, and a single mistake has the widest possible reach.

Split along two lines instead:

  • Account. Each account gets its own state, so a change to payments-prod cannot accidentally plan against orders-staging.
  • Component. Inside an account, separate things that change at different speeds: the account baseline, networking, and each workload's resources.

A layout that follows this:

repository layout
modules/
  account-baseline/
  vpc/
accounts/
  payments-prod/
    baseline/
      main.tf
      backend.hcl
    network/
      main.tf
      backend.hcl
  payments-staging/
    baseline/
    network/
  orders-prod/
    ...

Each directory under accounts/ is a small root module. It calls shared modules and passes in values for that account. The directory path is the address of the state, which makes it obvious what a pull request changes.

Where state lives

Keep all state in a dedicated account owned by the platform team, not in the accounts it describes. If a workload account is compromised or deleted, its state is still safe, and access to state is controlled in one place.

The S3 backend supports native locking with a lock file in the bucket since Terraform 1.10, so a DynamoDB table is no longer required. Backend blocks cannot use variables, so put the per-account values in a small file and pass it at init:

accounts/payments-prod/baseline/backend.hcl
bucket       = "platform-terraform-state"
key          = "accounts/payments-prod/baseline.tfstate"
region       = "eu-west-1"
use_lockfile = true
encrypt      = true
 
assume_role = {
  role_arn = "arn:aws:iam::111111111111:role/terraform-state"
}
terraform init -backend-config=backend.hcl

The state role in the platform account should only allow access to the matching key prefix. A pipeline for payments-prod has no reason to read the state of orders-prod.

Assume a role in each account

The provider should never hold long-lived keys for target accounts. Pipelines authenticate once, to a central identity, and the provider assumes a deployment role in the account it manages:

accounts/payments-prod/baseline/main.tf
provider "aws" {
  region              = "eu-west-1"
  allowed_account_ids = ["222222222222"]
 
  assume_role {
    role_arn     = "arn:aws:iam::222222222222:role/terraform-deploy"
    session_name = "terraform-payments-prod"
  }
 
  default_tags {
    tags = {
      account    = "payments-prod"
      managed-by = "terraform"
    }
  }
}
 
module "baseline" {
  source = "git::https://github.com/platform/terraform-modules.git//account-baseline?ref=v2.3.0"
 
  account_name    = "payments-prod"
  log_archive_arn = "arn:aws:s3:::platform-log-archive"
}

allowed_account_ids is cheap insurance. If credentials ever resolve to the wrong account, Terraform refuses to run instead of planning changes against it.

Create the terraform-deploy role in every account as part of account vending, with a trust policy that only allows the CI identity to assume it. Split it in two where it matters: a read-only role for plans on pull requests, and a role that can change resources for applies from the main branch.

RoleAssumed byPermissions
terraform-planCI jobs for pull requestsRead-only across the account
terraform-deployCI jobs on the main branch, behind approvalWhat the components in that account manage
terraform-stateBothObjects under that account's state prefix only

When one root module needs to touch two accounts, such as creating a VPC peering connection on both sides, declare a second provider with an alias and its own assume_role block, and pass it to the module explicitly with providers.

Shared modules, pinned versions

The account baseline should be one module, not fifty copies. Publish shared modules from a separate repository or a private registry, tag releases, and pin every account to a version with ?ref= or a version constraint.

Pinning lets you roll a change out gradually. Bump the version in a sandbox account first, then staging accounts, then production, each in its own pull request. With unpinned modules, a merge to the module repository changes every account at once on its next plan.

Keep the account root modules thin. If a root module contains much more than provider configuration, module calls and a few outputs, logic is probably being copied between accounts that belongs in a module.

Running plans across accounts in CI

With one directory per state, the CI job is the same everywhere and only the directory changes. Detect which directories a pull request touched and plan each one in parallel:

.github/workflows/terraform-plan.yml
on:
  pull_request:
    paths: ["accounts/**", "modules/**"]
 
permissions:
  id-token: write
  contents: read
  pull-requests: write
 
jobs:
  changed:
    runs-on: ubuntu-latest
    outputs:
      dirs: ${{ steps.dirs.outputs.dirs }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - id: dirs
        run: ./scripts/changed-roots.sh origin/${{ github.base_ref }} >> "$GITHUB_OUTPUT"
 
  plan:
    needs: changed
    if: needs.changed.outputs.dirs != '[]'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        dir: ${{ fromJson(needs.changed.outputs.dirs) }}
    defaults:
      run:
        working-directory: ${{ matrix.dir }}
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/ci-terraform
          aws-region: eu-west-1
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -backend-config=backend.hcl -input=false
      - run: terraform plan -input=false -lock-timeout=5m -out=tfplan

The script prints a JSON list of root directories, as dirs=[...], that contain changed files. A change under modules/ should map to every account that uses that module, which is easiest when modules are referenced by local path within the same repository, or when the script reads module sources from each root.

The CI job authenticates to one central role with OIDC. That role is the only principal allowed to assume terraform-plan and terraform-deploy in the target accounts. No access keys are stored in the pipeline.

Applies follow the same matrix on the main branch, with a protected environment per production account so that a reviewer approves before the job receives credentials.

Tools that help at scale

Plain Terraform with a matrix pipeline works for dozens of accounts. Beyond that, or when you want less glue code, consider:

  • Terragrunt, which generates backend and provider configuration from the directory path and can run commands across many directories.
  • Atlantis or a hosted Terraform service, which run plans per directory and post results on the pull request.
  • OpenTofu, which since 1.9 supports for_each on provider configurations, useful when one root module must manage the same resource across many regions or accounts.

Choose one deliberately. Most of the value comes from the conventions above, not from the tool.

Where to start

  1. Move all state into a dedicated state account with one key per account and component.
  2. Create terraform-plan and terraform-deploy roles in every account through your account vending process.
  3. Add allowed_account_ids and assume_role to every provider block and remove any stored keys.
  4. Pull the account baseline into one versioned module and pin every account to it.
  5. Build the plan matrix in CI, then add scheduled drift detection.