Back to Blog Hub

Multi-Account AWS Strategy: Landing Zones, Control Tower & Org-Level Networking

May 28, 2026 24 min read Architecture Governance MultiAccount

AWS Series | Part 19 — Building secure, cost-optimised, cloud-native infrastructure on AWS.

Multi-Account AWS Strategy — Landing Zones, Control Tower and Org-Level Networking

TL;DR

Concept What It Does When You Need It
AWS Organizations Groups accounts, enables consolidated billing, SCPs From Day 1 — even with 2 accounts
Control Tower Automated Landing Zone setup, guardrails, account vending 3+ accounts, compliance requirements
Landing Zone Baseline account structure with security, logging, networking pre-wired Any production enterprise
Service Control Policies Account-level permission guardrails — overrides everything Compliance, governance, preventing privilege escalation
AWS SSO / IAM Identity Center Centralised human access across all accounts 3+ accounts — never manage IAM users per account
Transit Gateway + RAM Shared networking hub across accounts Multi-account VPC connectivity
Centralised Security Account GuardDuty, Security Hub, CloudTrail aggregated here Regulated environments
Account Factory Automated new account provisioning with baseline config Scaling beyond 5 accounts

Introduction — Why One Account Is Never Enough

Every AWS journey starts with one account. It feels clean — one bill, one console, one set of credentials. Then the team grows. Dev and prod share the same account and a misconfigured IAM policy in a development script causes an outage in production. The security audit asks for proof that production data is isolated from development access. A new team needs their own environment but gets admin access to the whole account because nobody has time to scope permissions properly.

The move to multiple accounts is not a complexity choice — it is a security and governance choice. AWS accounts are the strongest isolation boundary available. A security incident in a dev account cannot affect production if they are separate accounts. A runaway cost spike in an experimentation account cannot affect the production budget if they have separate billing. An engineer with admin access in staging cannot accidentally modify production infrastructure if staging is a different account.

This post covers the complete multi-account strategy: how to structure an AWS Organisation, how to deploy a Landing Zone that pre-wires security, logging, and networking for every new account, how Control Tower automates this at scale, how to share networking across accounts via Transit Gateway and RAM, and how SCPs enforce governance that no IAM policy can override.

1. AWS Organizations — The Foundation

Account Structure — The OU Design

Organisational Units (OUs) are the grouping mechanism in AWS Organizations. OUs let you apply SCPs to groups of accounts rather than individual accounts — a policy attached to the Production OU applies to every account inside it automatically.

Root
├── Security OU
│   ├── Security-Tooling Account    (GuardDuty admin, Security Hub, Macie)
│   └── Log-Archive Account         (CloudTrail logs, Config records — immutable)
│
├── Infrastructure OU
│   ├── Networking Account          (TGW, Direct Connect, DNS)
│   └── Shared-Services Account     (ECR, Artifact Registry, tooling)
│
├── Workloads OU
│   ├── Production OU
│   │   ├── Production Account      (live workloads)
│   │   └── Production-DR Account   (disaster recovery — Blog 18)
│   └── Non-Production OU
│       ├── Development Account
│       ├── Staging Account
│       └── Sandbox Account         (experimental — no prod data allowed)
│
└── Management OU
    └── Management Account          (billing, Organizations, Control Tower)

Why this structure

The Security OU is isolated from everything else. Even if an attacker compromises a workload account, they cannot modify the security tooling or tamper with the log archive — those accounts have SCPs that deny all actions except from approved security roles.

The Networking account owns all shared connectivity (TGW, Direct Connect, Route 53). VPCs in workload accounts connect to the TGW via RAM-shared attachments. No workload account needs its own internet gateway or VPN connection.

The Production OU has stricter SCPs than Non-Production — production accounts cannot create public S3 buckets, cannot disable CloudTrail, and cannot deploy resources in non-EU regions.

Terraform — Organizations Structure

# Management account — creates the Organisation and OU structure
resource "aws_organizations_organization" "main" {
  aws_service_access_principals = [
    "cloudtrail.amazonaws.com",
    "config.amazonaws.com",
    "sso.amazonaws.com",
    "controltower.amazonaws.com",
    "guardduty.amazonaws.com",
    "securityhub.amazonaws.com",
    "macie.amazonaws.com",
    "ram.amazonaws.com",
    "cost-optimization-hub.bcm.amazonaws.com"
  ]

  feature_set = "ALL"   # Enables SCPs — do not use "CONSOLIDATED_BILLING" only
}

# Root OU — get the root ID
data "aws_organizations_organization" "main" {}

# Security OU
resource "aws_organizations_organizational_unit" "security" {
  name      = "Security"
  parent_id = data.aws_organizations_organization.main.roots[0].id
}

# Infrastructure OU
resource "aws_organizations_organizational_unit" "infrastructure" {
  name      = "Infrastructure"
  parent_id = data.aws_organizations_organization.main.roots[0].id
}

# Workloads OU
resource "aws_organizations_organizational_unit" "workloads" {
  name      = "Workloads"
  parent_id = data.aws_organizations_organization.main.roots[0].id
}

# Production OU — nested under Workloads
resource "aws_organizations_organizational_unit" "production" {
  name      = "Production"
  parent_id = aws_organizations_organizational_unit.workloads.id
}

# Non-Production OU — nested under Workloads
resource "aws_organizations_organizational_unit" "non_production" {
  name      = "Non-Production"
  parent_id = aws_organizations_organizational_unit.workloads.id
}

# Create accounts — vended into the correct OUs
resource "aws_organizations_account" "security_tooling" {
  name      = "security-tooling"
  email     = "aws-security-tooling@company.com"
  parent_id = aws_organizations_organizational_unit.security.id

  # Prevent accidental account closure
  close_on_deletion = false

  tags = { AccountType = "security", Environment = "all" }
}

resource "aws_organizations_account" "networking" {
  name      = "networking"
  email     = "aws-networking@company.com"
  parent_id = aws_organizations_organizational_unit.infrastructure.id

  close_on_deletion = false

  tags = { AccountType = "infrastructure", Environment = "all" }
}

resource "aws_organizations_account" "production" {
  name      = "production"
  email     = "aws-production@company.com"
  parent_id = aws_organizations_organizational_unit.production.id

  close_on_deletion = false

  tags = { AccountType = "workload", Environment = "prod" }
}

2. Service Control Policies — The Non-Overridable Guardrails

SCPs are the most powerful governance mechanism in AWS. They define the maximum permissions that any principal in an account can have — including the account root user. An explicit Deny in an SCP overrides every Allow in every IAM policy everywhere in that account.

Blog 8 covered SCPs in the context of IAM. This section covers the org-level SCP strategy.

SCP Architecture — Deny by Default vs Allow List

Two approaches:

  • Deny List (recommended): Start with FullAWSAccess managed policy at the root, then attach specific Deny SCPs at OU or account level. Simpler to manage — you only write rules for what you want to prevent.
  • Allow List: Remove FullAWSAccess, attach explicit Allow policies. More restrictive but requires listing every permitted action — maintenance-intensive.

Most enterprises use Deny List with targeted denies for high-risk actions.

Production OU SCPs

# SCP — production security baseline
resource "aws_organizations_policy" "production_guardrails" {
  name        = "production-security-guardrails"
  description = "Non-overridable security controls for all production accounts"
  type        = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [

      # 1. Cannot leave the Organisation
      {
        Sid    = "DenyLeaveOrganization"
        Effect = "Deny"
        Action = ["organizations:LeaveOrganization"]
        Resource = "*"
      },

      # 2. Cannot disable or modify CloudTrail
      {
        Sid    = "DenyCloudTrailModification"
        Effect = "Deny"
        Action = [
          "cloudtrail:DeleteTrail",
          "cloudtrail:StopLogging",
          "cloudtrail:UpdateTrail",
          "cloudtrail:PutEventSelectors"
        ]
        Resource = "*"
        Condition = {
          # Security account can still manage CloudTrail
          StringNotEquals = {
            "aws:PrincipalAccount" = var.security_tooling_account_id
          }
        }
      },

      # 3. Cannot disable GuardDuty
      {
        Sid    = "DenyGuardDutyModification"
        Effect = "Deny"
        Action = [
          "guardduty:DeleteDetector",
          "guardduty:DisassociateFromMasterAccount",
          "guardduty:StopMonitoringMembers",
          "guardduty:UpdateDetector"
        ]
        Resource = "*"
        Condition = {
          StringNotEquals = {
            "aws:PrincipalAccount" = var.security_tooling_account_id
          }
        }
      },

      # 4. Cannot make S3 buckets public
      {
        Sid    = "DenyS3PublicAccess"
        Effect = "Deny"
        Action = [
          "s3:PutBucketPublicAccessBlock",
          "s3:DeletePublicAccessBlock"
        ]
        Resource = "*"
        Condition = {
          # Only security account can modify public access blocks
          StringNotEquals = {
            "aws:PrincipalAccount" = var.security_tooling_account_id
          }
        }
      },

      # 5. Cannot deploy outside EU regions
      {
        Sid    = "DenyNonEURegions"
        Effect = "Deny"
        NotAction = [
          # Global services — must be excluded from region restriction
          "iam:*",
          "sts:*",
          "organizations:*",
          "route53:*",
          "cloudfront:*",
          "waf:*",
          "wafv2:*",
          "shield:*",
          "support:*",
          "trustedadvisor:*",
          "account:*",
          "billing:*",
          "budgets:*",
          "ce:*",
          "cur:*"
        ]
        Resource = "*"
        Condition = {
          StringNotEquals = {
            "aws:RequestedRegion" = [
              "eu-west-1",
              "eu-west-2",
              "eu-west-3",
              "eu-central-1",
              "eu-central-2",
              "eu-north-1",
              "eu-south-1",
              "eu-south-2"
            ]
          }
        }
      },

      # 6. Cannot create IAM users (human access via SSO only)
      {
        Sid    = "DenyIAMUserCreation"
        Effect = "Deny"
        Action = [
          "iam:CreateUser",
          "iam:CreateAccessKey"
        ]
        Resource = "*"
        Condition = {
          # Break-glass emergency account is the only exception
          StringNotEquals = {
            "aws:PrincipalArn" = "arn:aws:iam::${var.management_account_id}:root"
          }
        }
      },

      # 7. Protect the Log Archive account data
      {
        Sid    = "DenyLogArchiveModification"
        Effect = "Deny"
        Action = [
          "s3:DeleteBucket",
          "s3:DeleteObject",
          "s3:PutBucketPolicy",
          "s3:PutLifecycleConfiguration"
        ]
        Resource = "arn:aws:s3:::org-log-archive-*"
        Condition = {
          StringNotEquals = {
            "aws:PrincipalAccount" = var.log_archive_account_id
          }
        }
      }
    ]
  })
}

# Attach to Production OU
resource "aws_organizations_policy_attachment" "production_guardrails" {
  policy_id = aws_organizations_policy.production_guardrails.id
  target_id = aws_organizations_organizational_unit.production.id
}

Non-Production Additional Restrictions

# Additional SCP for Non-Production — no production data
resource "aws_organizations_policy" "nonprod_data_restrictions" {
  name = "nonprod-data-restrictions"
  type = "SERVICE_CONTROL_POLICY"

  content = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        # Prevent non-prod from accessing production S3 buckets
        Sid    = "DenyProductionDataAccess"
        Effect = "Deny"
        Action = ["s3:*"]
        Resource = [
          "arn:aws:s3:::*-prod-*",
          "arn:aws:s3:::*-prod-*/*"
        ]
      },
      {
        # Limit instance sizes in non-prod — cost control
        Sid    = "DenyLargeInstances"
        Effect = "Deny"
        Action = ["ec2:RunInstances"]
        Resource = "arn:aws:ec2:*:*:instance/*"
        Condition = {
          StringNotLike = {
            "ec2:InstanceType" = [
              "*.nano", "*.micro", "*.small",
              "*.medium", "*.large", "*.xlarge"
            ]
          }
        }
      }
    ]
  })
}

resource "aws_organizations_policy_attachment" "nonprod_restrictions" {
  policy_id = aws_organizations_policy.nonprod_data_restrictions.id
  target_id = aws_organizations_organizational_unit.non_production.id
}

3. AWS Control Tower — Automated Landing Zone

What Control Tower Does

Control Tower automates the creation and configuration of a multi-account Landing Zone — a pre-configured baseline that every new account inherits automatically. Without Control Tower, creating a new account that meets your security baseline requires a manual checklist: enable CloudTrail, enable GuardDuty, configure Security Hub, set up the networking, create the baseline IAM roles. With Control Tower, this happens automatically when a new account is vended.

Control Tower provides:

  • Landing Zone: Baseline account structure with Management, Log Archive, and Audit accounts pre-created
  • Guardrails: Pre-built SCPs and Config rules (proactive and detective controls)
  • Account Factory: Vend new accounts via Service Catalog — new account ready in ~20 minutes with all baseline config applied
  • Account Factory for Terraform (AFT): IaC-driven account vending via a Git pipeline

Control Tower Guardrails — Three Types

Type Mechanism Example
Preventive SCP Cannot disable CloudTrail in any account
Detective AWS Config Rule Alert if MFA not enabled on root account
Proactive CloudFormation hooks Block non-compliant resources before creation

Preventive guardrails are always enforced — they use SCPs under the hood. Detective guardrails report non-compliance but do not block. Proactive guardrails are the newest type — they evaluate resources before they are created.

Account Factory for Terraform (AFT)

AFT replaces the Service Catalog-based Account Factory with a Git-driven pipeline. A new account is requested by creating a Terraform file in a Git repository — the pipeline provisions the account, applies the baseline configuration, and runs customisations.

AFT Architecture:
Git PR: new account request
    ↓
AFT Pipeline (CodePipeline in Management account)
    ↓
Create AWS Account via Organizations
    ↓
Apply Account Baseline:
  - Enable CloudTrail
  - Enable GuardDuty (enrol in Security account)
  - Enable Security Hub
  - Create baseline IAM roles (AWSControlTowerExecution, etc.)
  - Apply account-level SCPs
    ↓
Apply Account Customisations:
  - VPC baseline (if networking customisation defined)
  - Tag policies
  - Team-specific IAM roles
    ↓
Account ready — team notified
# AFT account request — creates a new AWS account with full baseline
# Committed to the AFT account-requests repository
module "production_account" {
  source = "./modules/aft-account-request"

  control_tower_parameters = {
    AccountEmail              = "aws-new-service-prod@company.com"
    AccountName               = "new-service-production"
    ManagedOrganizationalUnit = "Production"
    SSOUserEmail              = "platform-lead@company.com"
    SSOUserFirstName          = "Platform"
    SSOUserLastName           = "Team"
  }

  account_tags = {
    Environment = "prod"
    Team        = "new-service"
    CostCentre  = "NEW-SVC-001"
  }

  # Run these customisations after account baseline is applied
  account_customizations_name = "production-workload"

  # Global customisations run in every account
  # (applied from the AFT global-customizations repository)
}
# AFT Global Customisation — runs in EVERY account that AFT provisions
# Stored in: aft-global-customizations/terraform/main.tf

# Enable Security Hub in every account
resource "aws_securityhub_account" "main" {}

resource "aws_securityhub_standards_subscription" "fsbp" {
  depends_on    = [aws_securityhub_account.main]
  standards_arn = "arn:aws:securityhub:${data.aws_region.current.name}::standards/aws-foundational-security-best-practices/v/1.0.0"
}

# Default VPC — delete it from every new account
# The default VPC is a security risk — open default security groups, public subnets
resource "null_resource" "delete_default_vpc" {
  provisioner "local-exec" {
    command = <<-EOF
      # Delete default VPC in all regions
      for region in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
        vpc_id=$(aws ec2 describe-vpcs \
          --region $region \
          --filters "Name=isDefault,Values=true" \
          --query 'Vpcs[0].VpcId' \
          --output text 2>/dev/null)

        if [ "$vpc_id" != "None" ] && [ -n "$vpc_id" ]; then
          echo "Deleting default VPC $vpc_id in $region"
          # Delete IGW, subnets, then VPC
          aws ec2 delete-vpc --vpc-id $vpc_id --region $region 2>/dev/null || true
        fi
      done
    EOF
  }
}

# Require IMDSv2 on all new EC2 instances — account-level default
resource "aws_ec2_instance_metadata_defaults" "imdsv2" {
  http_tokens = "required"   # IMDSv2 required for all new instances
}
Why delete the default VPC: The default VPC comes with a default security group that allows all inbound traffic from other resources in the same default security group, and all outbound traffic. It also has public subnets with internet access by default. Every new account starts with an open network — deleting it as part of account vending ensures every account starts with zero network exposure.

4. Centralised Networking — The Hub-and-Spoke Model

Blog 4 covered Transit Gateway architecture. In a multi-account context, the TGW lives in the Networking account and is shared to all workload accounts via RAM.

Networking Account (TGW owner)
├── Transit Gateway
├── Direct Connect / VPN (if hybrid connectivity needed)
├── Centralised Egress VPC (NAT Gateway for all spokes)
├── Centralised Inspection VPC (Network Firewall)
└── Route 53 Resolver Endpoints (hybrid DNS)

Workload Accounts (TGW consumers)
├── Production Account VPC → attached to TGW via RAM share
├── Staging Account VPC   → attached to TGW via RAM share
└── Dev Account VPC       → attached to TGW via RAM share

RAM — Share TGW Across the Organisation

# In the Networking account — share TGW with entire Organisation
resource "aws_ram_resource_share" "tgw" {
  name                      = "org-transit-gateway"
  allow_external_principals = false   # Organisation members only

  tags = { Name = "tgw-org-share" }
}

resource "aws_ram_resource_association" "tgw" {
  resource_arn       = aws_ec2_transit_gateway.main.arn
  resource_share_arn = aws_ram_resource_share.tgw.arn
}

resource "aws_ram_principal_association" "org" {
  principal          = data.aws_organizations_organization.main.arn
  resource_share_arn = aws_ram_resource_share.tgw.arn
}

# In each workload account — accept the RAM share and attach VPC
resource "aws_ec2_transit_gateway_vpc_attachment" "workload" {
  subnet_ids         = aws_subnet.private[*].id
  transit_gateway_id = var.shared_tgw_id   # From Networking account via RAM
  vpc_id             = aws_vpc.main.id

  tags = { Name = "tgw-attachment-${var.account_name}" }
}

Centralised Egress — All Internet Traffic Through One NAT Gateway

Without centralised egress, every account needs its own NAT Gateway. With centralised egress in the Networking account, all internet-bound traffic from all workload accounts flows through a single managed NAT Gateway fleet — easier to monitor, inspect, and control.

# In workload accounts — default route to TGW (not to local NAT GW)
resource "aws_route" "default_to_tgw" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "0.0.0.0/0"
  transit_gateway_id     = var.shared_tgw_id
  # Traffic goes to TGW → Networking account → Centralised NAT → Internet
}

# In Networking account — TGW route table sends 0.0.0.0/0 to Egress VPC
resource "aws_ec2_transit_gateway_route" "default_to_egress" {
  destination_cidr_block         = "0.0.0.0/0"
  transit_gateway_attachment_id  = aws_ec2_transit_gateway_vpc_attachment.egress.id
  transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.workloads.id
}

Centralised DNS — Route 53 Resolver in Networking Account

# In Networking account — Resolver Endpoints serve all accounts
resource "aws_route53_resolver_endpoint" "outbound" {
  name      = "org-outbound-resolver"
  direction = "OUTBOUND"

  security_group_ids = [aws_security_group.resolver.id]

  # One IP per AZ — deployed in the Networking account's VPC
  ip_address { subnet_id = aws_subnet.networking_private["eu-west-1a"].id }
  ip_address { subnet_id = aws_subnet.networking_private["eu-west-1b"].id }
  ip_address { subnet_id = aws_subnet.networking_private["eu-west-1c"].id }

  tags = { Name = "org-outbound-resolver" }
}

# Forwarding rule — shared to all accounts via RAM
resource "aws_route53_resolver_rule" "corp_forward" {
  domain_name          = "corp.company.com"
  name                 = "forward-to-onprem"
  rule_type            = "FORWARD"
  resolver_endpoint_id = aws_route53_resolver_endpoint.outbound.id

  target_ip { ip = "192.168.1.10" }
  target_ip { ip = "192.168.1.11" }
}

resource "aws_ram_resource_association" "resolver_rule" {
  resource_arn       = aws_route53_resolver_rule.corp_forward.arn
  resource_share_arn = aws_ram_resource_share.tgw.arn
}

# In each workload account — associate the shared resolver rule
resource "aws_route53_resolver_rule_association" "corp" {
  resolver_rule_id = var.shared_resolver_rule_id
  vpc_id           = aws_vpc.main.id
}

5. Centralised Security — The Security Account Model

All security tooling lives in the Security-Tooling account. Workload accounts enrol as members — they send findings to the central account but cannot modify the security configuration.

# In Security-Tooling account — delegate administration
resource "aws_guardduty_organization_admin_account" "security" {
  admin_account_id = data.aws_caller_identity.security.account_id
}

resource "aws_securityhub_organization_admin_account" "security" {
  admin_account_id = data.aws_caller_identity.security.account_id
}

# Organisation-wide GuardDuty — auto-enable in all accounts
resource "aws_guardduty_organization_configuration" "main" {
  auto_enable_organization_members = "ALL"
  detector_id                      = aws_guardduty_detector.main.id

  datasources {
    s3_logs { auto_enable = true }
    kubernetes { audit_logs { enable = true } }
    malware_protection {
      scan_ec2_instance_with_findings {
        ebs_volumes { auto_enable = true }
      }
    }
  }
}

# Organisation-wide CloudTrail — one trail, all accounts, all regions
resource "aws_cloudtrail" "org" {
  name                          = "org-audit-trail"
  s3_bucket_name                = aws_s3_bucket.log_archive.bucket   # Log Archive account
  include_global_service_events = true
  is_multi_region_trail         = true
  is_organization_trail         = true
  enable_log_file_validation    = true
  kms_key_id                    = aws_kms_key.cloudtrail.arn
  cloud_watch_logs_group_arn    = "${aws_cloudwatch_log_group.cloudtrail.arn}:*"
  cloud_watch_logs_role_arn     = aws_iam_role.cloudtrail_cw.arn

  tags = { Name = "org-audit-trail" }
}

Log Archive Account — Immutable Audit Logs

# S3 bucket in Log Archive account — receives CloudTrail logs from all accounts
resource "aws_s3_bucket" "log_archive" {
  provider = aws.log_archive

  bucket = "org-log-archive-${var.org_id}"

  lifecycle {
    prevent_destroy = true   # Never delete this bucket
  }
}

resource "aws_s3_bucket_object_lock_configuration" "log_archive" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.log_archive.bucket

  rule {
    default_retention {
      mode = "COMPLIANCE"   # Cannot be overridden — not even by root
      days = 2555           # 7 years — common compliance requirement
    }
  }
}

# Bucket policy — only CloudTrail service and Security account can write
# No account (including Log Archive itself) can delete objects
resource "aws_s3_bucket_policy" "log_archive" {
  provider = aws.log_archive
  bucket   = aws_s3_bucket.log_archive.bucket

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowCloudTrailWrite"
        Effect = "Allow"
        Principal = { Service = "cloudtrail.amazonaws.com" }
        Action    = ["s3:PutObject"]
        Resource  = "${aws_s3_bucket.log_archive.arn}/AWSLogs/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl" = "bucket-owner-full-control"
          }
        }
      },
      {
        Sid    = "DenyDeleteForEveryone"
        Effect = "Deny"
        Principal = { AWS = "*" }
        Action    = ["s3:DeleteObject", "s3:DeleteObjectVersion"]
        Resource  = "${aws_s3_bucket.log_archive.arn}/*"
      }
    ]
  })
}

6. IAM Identity Center — Human Access Across All Accounts

Blog 8 covered IAM Identity Center in the context of single-account access. In a multi-account organisation, Identity Center becomes the single authentication point for all human access.

# Permission sets — define what a role can do in an account
resource "aws_ssoadmin_permission_set" "platform_engineer" {
  name             = "PlatformEngineer"
  instance_arn     = data.aws_ssoadmin_instances.main.arns[0]
  session_duration = "PT8H"
  description      = "Full access excluding IAM and billing — for platform team"
}

resource "aws_ssoadmin_managed_policy_attachment" "platform_poweruser" {
  instance_arn       = data.aws_ssoadmin_instances.main.arns[0]
  permission_set_arn = aws_ssoadmin_permission_set.platform_engineer.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}

resource "aws_ssoadmin_permission_set" "developer" {
  name             = "Developer"
  instance_arn     = data.aws_ssoadmin_instances.main.arns[0]
  session_duration = "PT8H"
}

resource "aws_ssoadmin_permission_set" "readonly" {
  name             = "ReadOnly"
  instance_arn     = data.aws_ssoadmin_instances.main.arns[0]
  session_duration = "PT4H"
}

resource "aws_ssoadmin_managed_policy_attachment" "readonly" {
  instance_arn       = data.aws_ssoadmin_instances.main.arns[0]
  permission_set_arn = aws_ssoadmin_permission_set.readonly.arn
  managed_policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

# Account assignments — who gets what access in which account
# Platform team: full access to networking + security accounts
resource "aws_ssoadmin_account_assignment" "platform_networking" {
  instance_arn       = data.aws_ssoadmin_instances.main.arns[0]
  permission_set_arn = aws_ssoadmin_permission_set.platform_engineer.arn
  principal_type     = "GROUP"
  principal_id       = data.aws_identitystore_group.platform.group_id
  target_type        = "AWS_ACCOUNT"
  target_id          = aws_organizations_account.networking.id
}

# Developers: ReadOnly in production, Developer in non-production
resource "aws_ssoadmin_account_assignment" "dev_prod_readonly" {
  instance_arn       = data.aws_ssoadmin_instances.main.arns[0]
  permission_set_arn = aws_ssoadmin_permission_set.readonly.arn
  principal_type     = "GROUP"
  principal_id       = data.aws_identitystore_group.developers.group_id
  target_type        = "AWS_ACCOUNT"
  target_id          = aws_organizations_account.production.id
}

resource "aws_ssoadmin_account_assignment" "dev_nonprod_developer" {
  instance_arn       = data.aws_ssoadmin_instances.main.arns[0]
  permission_set_arn = aws_ssoadmin_permission_set.developer.arn
  principal_type     = "GROUP"
  principal_id       = data.aws_identitystore_group.developers.group_id
  target_type        = "AWS_ACCOUNT"
  target_id          = aws_organizations_account.development.id
}

7. Cross-Account Terraform — The IaC Pattern

Managing infrastructure across multiple accounts from a single Terraform codebase requires provider aliases and careful state separation (covered in Blog 14).

# providers.tf — one provider alias per account
provider "aws" {
  alias = "management"
  assume_role {
    role_arn = "arn:aws:iam::${var.management_account_id}:role/terraform-apply-role"
  }
  region = var.region
}

provider "aws" {
  alias = "networking"
  assume_role {
    role_arn = "arn:aws:iam::${var.networking_account_id}:role/terraform-apply-role"
  }
  region = var.region
}

provider "aws" {
  alias = "security"
  assume_role {
    role_arn = "arn:aws:iam::${var.security_account_id}:role/terraform-apply-role"
  }
  region = var.region
}

provider "aws" {
  alias = "production"
  assume_role {
    role_arn = "arn:aws:iam::${var.production_account_id}:role/terraform-apply-role"
  }
  region = var.region
}

# Terraform apply role — exists in every account
# Created by Control Tower / AFT baseline customisation
# Trusted by the Management account's Terraform CI/CD role
resource "aws_iam_role" "terraform_apply" {
  for_each = toset([
    var.management_account_id,
    var.networking_account_id,
    var.security_account_id,
    var.production_account_id
  ])

  name = "terraform-apply-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        AWS = "arn:aws:iam::${var.management_account_id}:role/terraform-ci-role"
      }
      Action    = "sts:AssumeRole"
      Condition = {
        StringEquals = {
          "sts:ExternalId" = var.terraform_external_id
        }
      }
    }]
  })
}

8. The Landing Zone Checklist

Every new account vended by AFT should arrive with this baseline applied automatically:

Account Baseline — applied by AFT Global Customisations:
  ✅ Default VPC deleted in all regions
  ✅ IMDSv2 required on all new EC2 instances
  ✅ S3 Block Public Access enabled at account level
  ✅ EBS encryption by default enabled
  ✅ GuardDuty enrolled in Security account
  ✅ Security Hub enrolled + FSBP standard enabled
  ✅ CloudTrail Organisation trail covering this account
  ✅ AWS Config recorder enabled
  ✅ Cost allocation tags activated
  ✅ Budget alert at $X (configurable per account)
  ✅ Baseline IAM roles created (terraform-apply, read-only, break-glass)
  ✅ SSO permission sets assigned to correct groups
  ✅ VPC baseline (if networking customisation requested)
  ✅ TGW attachment (if networking customisation requested)

Account-Specific Customisations (per team):
  - Additional IAM roles for workloads
  - ECR repositories
  - Secrets Manager baseline secrets
  - Service-specific Config rules

9. Common Mistakes & Anti-Patterns

Mistake 1: One Account for Everything

The most common starting mistake. Dev and prod sharing an account means a developer with sufficient IAM permissions can accidentally or intentionally affect production. AWS accounts are the strongest isolation boundary — use them.

Mistake 2: SCPs That Block AWS Service Operations

SCPs that deny specific actions without excluding the AWS service principals that need them to operate break services silently. For example, denying iam:PassRole without excluding ec2.amazonaws.com breaks EC2 instance launch. Always test SCP changes in a non-production OU before applying to production.

Mistake 3: Not Deleting the Default VPC

Every new account comes with a default VPC with public subnets, an internet gateway, and a permissive default security group. Engineers who deploy into the default VPC accidentally expose resources to the internet. Delete it as part of account vending.

Mistake 4: Management Account Used for Workloads

The Management account has elevated privileges — it can see and affect every account in the organisation. Running production workloads in the Management account means a security incident in your production application has access to Organisation-level APIs. The Management account should contain only: Organizations, Control Tower, SSO, consolidated billing, and Terraform state for org-level infrastructure.

Mistake 5: No Break-Glass Procedure

SSO is the correct access mechanism for daily work. But SSO depends on your identity provider being available. If your Okta tenant has an incident at the same time as an AWS production incident, you cannot access your AWS accounts to respond. Maintain one IAM user per account (or a cross-account break-glass role from the Management account) for emergency access — documented, MFA-protected, and audited in CloudTrail.

Mistake 6: Applying SCPs Before Testing in Non-Prod

A badly written SCP can deny actions required by AWS-managed services, breaking services like Config, Security Hub, or ECS. Always test new SCPs in the Sandbox OU first, run a Terraform plan to see what would change, then promote to Non-Production, then Production.

Mistake 7: Sharing the TGW Without Route Table Isolation

A Transit Gateway shared to all accounts via RAM that uses a single default route table means every account can route to every other account — dev can reach production. Apply the route table isolation pattern from Blog 4: separate route tables per OU, Production VPCs only propagate to Production route tables.

Architecture Decision Matrix

Capability Single Account Multi-Account (Manual) Multi-Account + Control Tower
Blast radius isolation ❌ None ✅ Account boundary ✅ Account + SCP boundary
Compliance audit trail ⚠️ Per-account ⚠️ Per-account ✅ Org-wide CloudTrail
New account provisioning N/A Manual (~2 days) Automated via AFT (~20 min)
Centralised security findings ✅ GuardDuty + Security Hub
Human access management Per-account IAM users Per-account IAM users ✅ IAM Identity Center (SSO)
Network governance Per-account ✅ Centralised TGW + Networking account
Cost allocation ❌ All in one bill ⚠️ Account-level ✅ Account + tag-level
SCP governance Manual ✅ OU-based, automated
Operational complexity Low High (manual) Medium (automated)
Recommended for POC, personal projects Small teams (<5 accounts) Any production organisation

The Golden Rule

"A single AWS account is a starting point, not a destination. Move to multiple accounts the moment you have a team, a regulatory requirement, or a distinction between production and non-production that you need to enforce structurally — not just by convention. Use the OU structure to group accounts by function and risk level, not by team name. Apply SCPs at the OU level so governance is automatic, not manual. Centralise security tooling in a dedicated Security account that no workload team can modify. Share networking through a Networking account's Transit Gateway so every workload account gets private connectivity without owning its own NAT Gateways. And vend every new account with a complete, automated baseline — delete the default VPC, enable IMDSv2, enrol in GuardDuty and Security Hub — before any human ever logs in."

Ankush Panday

Specializing in highly scalable AWS infrastructure and automated quality engineering.

Connect on LinkedIn