Table of Contents
Introduction
As organizations grow, a single AWS account quickly becomes a limitation. Security boundaries blur, billing gets complicated, and blast radius from mistakes affects everything. AWS recommends a multi-account strategy where different workloads, environments, and teams operate in isolated accounts.
Terraform is perfectly suited for managing this complexity. With provider aliases, assume role configurations, and modular design, you can orchestrate resources across dozens of AWS accounts from a single codebase. This article covers practical patterns for managing multi-account AWS environments with Terraform.
Why Multiple AWS Accounts?
Before diving into the implementation, let’s understand why organizations adopt multi-account strategies:
- Security isolation: A compromised development account can’t access production data
- Billing separation: Clear cost attribution per team, project, or environment
- Service limits: Each account gets its own AWS service quotas
- Compliance: Regulatory requirements often mandate environment separation
- Blast radius reduction: Mistakes in one account don’t cascade to others
Provider Aliases for Multiple Accounts
Terraform’s provider alias feature lets you configure multiple AWS providers, each targeting a different account:
# providers.tf
provider "aws" {
region = "us-east-1"
alias = "management"
}
provider "aws" {
region = "us-east-1"
alias = "production"
assume_role {
role_arn = "arn:aws:iam::role/TerraformExecutionRole"
session_name = "terraform-production"
external_id = "terraform-prod-2025"
}
}
provider "aws" {
region = "us-east-1"
alias = "staging"
assume_role {
role_arn = "arn:aws:iam::role/TerraformExecutionRole"
session_name = "terraform-staging"
}
}
provider "aws" {
region = "us-east-1"
alias = "development"
assume_role {
role_arn = "arn:aws:iam::role/TerraformExecutionRole"
session_name = "terraform-development"
}
}
Use the provider alias when creating resources:
resource "aws_vpc" "production" {
provider = aws.production
cidr_block = "10.1.0.0/16"
tags = {
Name = "production-vpc"
Environment = "production"
}
}
resource "aws_vpc" "staging" {
provider = aws.staging
cidr_block = "10.2.0.0/16"
tags = {
Name = "staging-vpc"
Environment = "staging"
}
}
Setting Up Cross-Account IAM Roles
Each target account needs an IAM role that Terraform can assume. Create this role in every account:
# Module: cross-account-role
variable "management_account_id" {
description = "The AWS account ID of the management account"
type = string
}
resource "aws_iam_role" "terraform_execution" {
name = "TerraformExecutionRole"
max_session_duration = 3600
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.management_account_id}:root"
}
Action = "sts:AssumeRole"
Condition = {
StringEquals = {
"sts:ExternalId" = "terraform-cross-account"
}
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "admin" {
role = aws_iam_role.terraform_execution.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
For production environments, replace AdministratorAccess with a custom policy that grants only the permissions Terraform actually needs.
Dynamic Provider Configuration with Variables
For more flexible multi-account setups, use variables to configure providers dynamically:
# variables.tf
variable "accounts" {
description = "Map of account configurations"
type = map(object({
account_id = string
region = string
environment = string
}))
default = {
production = {
account_id = "111111111111"
region = "us-east-1"
environment = "production"
}
staging = {
account_id = "222222222222"
region = "us-east-1"
environment = "staging"
}
development = {
account_id = "333333333333"
region = "us-west-2"
environment = "development"
}
}
}
AWS Organizations Integration
If you’re using AWS Organizations, Terraform can manage the organization structure itself:
resource "aws_organizations_organization" "main" {
aws_service_access_principals = [
"cloudtrail.amazonaws.com",
"config.amazonaws.com",
"sso.amazonaws.com",
]
feature_set = "ALL"
enabled_policy_types = [
"SERVICE_CONTROL_POLICY",
"TAG_POLICY",
]
}
resource "aws_organizations_organizational_unit" "workloads" {
name = "Workloads"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_organizational_unit" "production" {
name = "Production"
parent_id = aws_organizations_organizational_unit.workloads.id
}
resource "aws_organizations_account" "prod_app" {
name = "prod-application"
email = "[email protected]"
parent_id = aws_organizations_organizational_unit.production.id
role_name = "TerraformExecutionRole"
lifecycle {
ignore_changes = [role_name]
}
}
Service Control Policies (SCPs)
Enforce guardrails across all accounts using SCPs:
resource "aws_organizations_policy" "deny_regions" {
name = "deny-unauthorized-regions"
description = "Deny access to regions not approved for use"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "DenyUnapprovedRegions"
Effect = "Deny"
Action = "*"
Resource = "*"
Condition = {
StringNotEquals = {
"aws:RequestedRegion" = ["us-east-1", "us-west-2", "eu-west-1"]
}
}
}
]
})
}
resource "aws_organizations_policy_attachment" "deny_regions" {
policy_id = aws_organizations_policy.deny_regions.id
target_id = aws_organizations_organizational_unit.workloads.id
}
Shared Resources Across Accounts
Some resources need to be shared across accounts. A common pattern is sharing VPCs using AWS RAM (Resource Access Manager):
# In the networking account
resource "aws_vpc" "shared" {
provider = aws.networking
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "shared_private" {
provider = aws.networking
vpc_id = aws_vpc.shared.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
}
resource "aws_ram_resource_share" "shared_subnets" {
provider = aws.networking
name = "shared-subnets"
allow_external_principals = false
}
resource "aws_ram_resource_association" "subnet" {
provider = aws.networking
resource_arn = aws_subnet.shared_private.arn
resource_share_arn = aws_ram_resource_share.shared_subnets.arn
}
resource "aws_ram_principal_association" "prod_account" {
provider = aws.networking
principal = var.accounts["production"].account_id
resource_share_arn = aws_ram_resource_share.shared_subnets.arn
}
Project Structure
A recommended directory structure for multi-account Terraform:
├── modules/
│ ├── networking/
│ ├── security/
│ └── compute/
├── accounts/
│ ├── management/
│ │ ├── main.tf
│ │ ├── backend.tf
│ │ └── variables.tf
│ ├── production/
│ │ ├── main.tf
│ │ ├── backend.tf
│ │ └── variables.tf
│ └── staging/
│ ├── main.tf
│ ├── backend.tf
│ └── variables.tf
├── organization/
│ ├── main.tf
│ ├── accounts.tf
│ └── scps.tf
└── global/
├── iam.tf
└── dns.tf
Best Practices
- Use separate state files per account — Reduces blast radius and enables parallel operations
- Implement least-privilege IAM — The cross-account role should only have permissions Terraform needs
- Centralize logging — Send CloudTrail logs from all accounts to a dedicated logging account
- Tag everything — Enforce tagging policies via SCPs to maintain cost visibility
- Use modules — Share common infrastructure patterns across accounts via Terraform modules
- Separate management from workloads — The account running Terraform should be isolated from application workloads
Conclusion
Managing multiple AWS accounts with Terraform brings order to complex cloud environments. By leveraging provider aliases, cross-account IAM roles, and AWS Organizations, you can maintain consistent infrastructure across dozens of accounts while preserving the security and isolation benefits of multi-account architecture. Start with a clear account structure, implement proper IAM roles, and use modules to keep your configurations DRY and maintainable.

