Running AWS at scale means managing more than one account, yet many teams still treat the cloud as a single sandbox. When your CI/CD pipeline, production database, and developer playgrounds all share the same AWS account, a misconfigured policy or runaway instance can take down your entire operation. Multi-account AWS with Terraform: lessons from production teaches us that separation, governance, and automation are not optional—they're the foundation of reliable infrastructure.
Why multi-account architecture matters
We've seen teams operate successfully with a single AWS account until they hit about three years of growth. At that point, blast radius becomes real: a developer accidentally deletes a production security group, or a compromised credential grants access to every database. Multi-account separation is the most practical insurance policy you can implement.
Multi-account architecture isolates blast radius, enforces least privilege at the account level, and creates natural boundaries for teams. It also simplifies cost allocation—you can assign accounts to business units and see exactly who is spending what. From a compliance perspective, regulators often require this level of segregation anyway.
The trade-off is operational complexity. You now manage multiple AWS accounts, cross-account IAM roles, and state files across environments. Terraform makes this manageable, but you need a clear pattern.
Account per environment—separate prod from dev to limit blast radius
The simplest multi-account strategy is one account per environment: development, staging, and production. This immediately solves the most critical problem—a mistake in dev cannot affect prod.
In practice, we set up three AWS accounts from the start. Our development team deploys freely in the dev account without worrying about breaking shared infrastructure. The staging account runs on production-like hardware and policies, so we catch issues before they reach prod. The production account runs with the strictest controls: limited manual access, extensive audit logging, and automated infrastructure changes only through CI/CD.
Here's how we organize the Terraform code:
terraform/
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
├── modules/
│ ├── networking/
│ ├── compute/
│ └── database/
└── globals.tfEach environment directory contains its own Terraform backend configuration pointing to a different AWS account and S3 bucket. The terraform.tfvars file specifies environment-specific values: instance sizes, replica counts, backup retention, and so on.
# environments/prod/backend.tf
terraform {
backend "s3" {
bucket = "acme-prod-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks-prod"
}
}
# environments/prod/terraform.tfvars
instance_type = "t3.large"
db_instance_type = "db.r5.2xlarge"
backup_retention = 30
enable_multiaz = true
This pattern makes it obvious which changes affect which environment. You deploy to dev first, verify behavior, promote to staging, run integration tests, then merge to prod only after approval.
AWS Organizations—manage accounts and SCPs as code
Once you have three or more accounts, manually managing them becomes error-prone. AWS Organizations lets you create accounts programmatically, organize them into organizational units (OUs), and apply Service Control Policies (SCPs) at scale.
We use Terraform to define our organization structure. At the root level, we create an SCP that denies access to certain high-risk actions across all accounts—for example, disabling CloudTrail or deleting the organization itself. This becomes your safety net.
# organizations.tf
resource "aws_organizations_organization" "main" {
aws_service_access_principals = [
"cloudtrail.amazonaws.com",
"config.amazonaws.com",
"securityhub.amazonaws.com",
]
enabled_policy_types = ["SERVICE_CONTROL_POLICY"]
}
resource "aws_organizations_organizational_unit" "production" {
name = "production"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_organizational_unit" "development" {
name = "development"
parent_id = aws_organizations_organization.main.roots[0].id
}
resource "aws_organizations_policy" "prevent_dangerous_actions" {
name = "PreventDangerousActions"
type = "SERVICE_CONTROL_POLICY"
content = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Deny"
Action = [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"organizations:LeaveOrganization",
]
Resource = "*"
}
]
})
}
resource "aws_organizations_policy_attachment" "prod_policy" {
policy_id = aws_organizations_policy.prevent_dangerous_actions.id
target_id = aws_organizations_organizational_unit.production.id
}
We also create accounts via Terraform. This ensures every account follows the same baseline: CloudTrail enabled, Config activated, a foundational networking setup.
resource "aws_organizations_account" "prod" {
name = "acme-prod"
email = "aws-prod@acme.com"
parent_id = aws_organizations_organizational_unit.production.id
}
resource "aws_organizations_account" "dev" {
name = "acme-dev"
email = "aws-dev@acme.com"
parent_id = aws_organizations_organizational_unit.development.id
}
SCPs are not permissions—they're guardrails. A user with full IAM rights in an account still cannot violate a Deny statement in an SCP attached to that account's OU. This prevents entire categories of mistakes without requiring explicit allow policies everywhere.
Assume roles—provider assume_role per account
To deploy infrastructure in different accounts from a single Terraform configuration or CI/CD pipeline, you assume an IAM role in the target account. The Terraform AWS provider supports this natively via the assume_role block.
Set up a cross-account role in each target account that trusts your CI/CD system (e.g., GitHub Actions, GitLab CI, or Jenkins). This role has the minimum permissions needed to deploy infrastructure in that account.
# In prod account: roles.tf
resource "aws_iam_role" "terraform" {
name = "TerraformCrossAccountRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
# CI/CD role or specific AWS account
AWS = "arn:aws:iam::${var.ci_cd_account_id}:role/GitHubActionsRole"
}
}
]
})
}
resource "aws_iam_role_policy_attachment" "terraform_policy" {
role = aws_iam_role.terraform.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
# In production, replace with a custom policy that grants only needed permissions
}
Then, in your Terraform configuration, specify assume_role when configuring the AWS provider for each environment:
# main.tf
provider "aws" {
region = var.aws_region
assume_role {
role_arn = "arn:aws:iam::${var.target_account_id}:role/TerraformCrossAccountRole"
}
}
In your CI/CD pipeline, you authenticate once (e.g., via OIDC or temporary credentials) to your CI/CD AWS account or IAM user, then assume the cross-account role automatically. This gives you fine-grained control: different deployments can assume different roles with different permission levels.
For example, a deploy to staging might assume a role with broad permissions, while a deploy to prod assumes a role that only allows specific resource types and requires a second approval step.
Landing zones—control tower or custom baselines
A landing zone is a baseline—a set of guardrails, logging, and networking standards applied to every new account. AWS Control Tower provides a managed landing zone, but many teams build custom baselines with Terraform.
The baseline typically includes:
- VPC with public and private subnets, NAT gateway, and flow logs
- CloudTrail sending logs to a central S3 bucket in a logging account
- AWS Config evaluating compliance rules
- VPC peering or Transit Gateway connectivity to other accounts
- Foundational security groups and NACLs
- KMS keys for encryption at rest
We create a Terraform module that applies this baseline to any new account:
# modules/landing_zone/main.tf
module "landing_zone" {
source = "./modules/landing_zone"
account_id = aws_organizations_account.prod.id
environment = "prod"
vpc_cidr = "10.0.0.0/16"
enable_flow_logs = true
central_logging_bucket = aws_s3_bucket.central_logs.id
kms_key_id = aws_kms_key.central.id
}
When we create a new AWS account via Organizations, Terraform also applies the landing zone module to it. Within hours, that account has proper logging, networking, and encryption configured. This prevents the common scenario where someone stands up an account and forgets to enable CloudTrail.
For organizations that want even more structure, AWS Control Tower provides an opinionated landing zone with managed SCPs and automated guardrail updates. Terraform integrates with Control Tower via the AWSServiceRoleForServiceRoleForOrganizations service-linked role. Some teams use Control Tower for the organization layer and Terraform for account-level customization.
Common mistakes
- Hardcoding account IDs in Terraform. Use variables and a centralized account registry instead. When you have 20 accounts, updating a hardcoded ID in ten places is a recipe for mistakes.
- Forgetting to enable CloudTrail in all accounts. This defeats the entire purpose of multi-account architecture. Make it a non-negotiable part of your landing zone.
- Not setting up cross-account S3 buckets for state. If all environments' state lives in the prod account, a compromised credential in dev could expose prod state. Use separate buckets in separate accounts.
- Overly permissive assume role policies. If your dev CI/CD can assume a role in prod, the blast radius is back. Use STS session tags and conditional policies to enforce environment isolation.
- Ignoring cost allocation tags. Multi-account architecture is partly about cost visibility. Tag every resource with owner, environment, and application. Then use AWS Cost Explorer to see who is spending what.
Key takeaways
- Separate accounts by environment (dev, staging, prod) to limit blast radius and enforce least privilege at the AWS API level.
- Use AWS Organizations to manage multiple accounts and apply Service Control Policies as code for organization-wide guardrails.
- Assume cross-account IAM roles in your Terraform provider to deploy from a single CI/CD pipeline while maintaining account isolation.
- Build a landing zone baseline with Terraform and apply it to every new account: logging, networking, encryption, and compliance checks must be automatic, not manual.
- Store state files in separate AWS accounts and S3 buckets per environment, with encryption and locking enabled.
- Invest early in a clear account strategy—retrofitting it later costs more than building it right the first time.






Comments
Loading comments…