Skip to content
Back to blog AWS Account Provisioning at Scale with Control Tower, Service Catalog, and Terraform

AWS Account Provisioning at Scale with Control Tower, Service Catalog, and Terraform

AWSPlatform Engineering

AWS Account Provisioning at Scale with Control Tower, Service Catalog, and Terraform

When you’re running a platform for hundreds of microservices, account sprawl is inevitable. Teams need isolated environments – dev, staging, prod – and you need guardrails, SSO access, networking, and baseline security in every single one.

Doing this manually doesn’t scale. At a previous company, I built an automated account vending machine that could spin up a fully configured AWS account in under 30 minutes: enrolled in Control Tower, SSO access configured, baseline IAM roles deployed, and ready for application workloads.

This post covers the architecture and Terraform implementation – how we used Control Tower Account Factory via Service Catalog, CloudFormation StackSets for cross-account role deployment, and a modular Terraform structure that made provisioning new accounts a single PR.

The Flow

Account provisioning architecture

The provisioning flow:

  1. Developer requests account via PR to the account-provisioning repo
  2. Terraform provisions the account via Service Catalog (Control Tower Account Factory)
  3. Control Tower enrolls the account, applies guardrails, sets up CloudTrail/Config
  4. StackSet deploys baseline IAM roles to the new account
  5. SSO user created automatically with access to the account
  6. Account metadata written to S3 for billing/tagging systems
  7. CI/CD stack created with permissions to deploy to the new account

The entire process is GitOps-driven. No console clicks, no manual steps, full audit trail.

Prerequisites

Before implementing this, you need:

  • AWS Organizations with a management (billing) account
  • Control Tower enabled and configured
  • Service Catalog with the Control Tower Account Factory product
  • At least one registered OU (Organizational Unit) in Control Tower
  • IAM Identity Center (SSO) configured
  • A CI/CD platform with AWS integration (we used a Terraform automation platform)

Module Structure

account-provisioning/
├── modules/
│   ├── account/           # Creates AWS account via Service Catalog
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── metadata.tf
│   └── stack/             # Creates CI/CD stack for the OU
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── ou/
    ├── stacks.tf          # CI/CD stack definitions per OU
    ├── platform/
    │   ├── my-service/
    │   │   └── main.tf    # Account definitions
    │   └── another-service/
    │       └── main.tf
    └── infrastructure/
        ├── dns/
        │   └── main.tf
        └── networking/
            └── main.tf

Each directory under ou/ corresponds to a registered Organizational Unit. Accounts are defined within their respective OU folders.

The Account Module

This is the core module that provisions accounts via Control Tower Account Factory.

modules/account/variables.tf

variable "name" {
  description = "Account name (must be unique across the organization)"
  type        = string
}

variable "email" {
  description = "Root email for the account (must be unique, use + addressing)"
  type        = string
}

variable "parent_ou_id" {
  description = "Parent OU ID for account lookups"
  type        = string
}

variable "ou_id" {
  description = "Target OU ID where the account will be placed"
  type        = string
}

variable "sso_user_firstname" {
  description = "First name for the SSO user"
  type        = string
}

variable "sso_user_lastname" {
  description = "Last name for the SSO user"
  type        = string
}

variable "alias" {
  description = "Account alias (defaults to name)"
  type        = string
  default     = null
}

variable "account_deployment_type" {
  description = "Deployment type for billing categorization"
  type        = string
  default     = null
}

variable "account_bill_type" {
  description = "Billing type (Prod/NonProd/Data)"
  type        = string
  default     = null
}

modules/account/main.tf

locals {
  # After account creation, look it up by name to get the account ID
  account_lookup = [
    for account in data.aws_organizations_organizational_unit_descendant_accounts.accounts.accounts : 
    account if account.name == var.name
  ]
  account_id = length(local.account_lookup) > 0 ? local.account_lookup[0].id : null
}

# Look up all accounts in the parent OU to find our newly created account
data "aws_organizations_organizational_unit_descendant_accounts" "accounts" {
  depends_on = [aws_cloudformation_stack_set_instance.deploy_baseline_roles]
  parent_id  = var.parent_ou_id
}

# This is the magic - Service Catalog provisions the account via Control Tower
resource "aws_servicecatalog_provisioned_product" "account" {
  name                       = var.name
  product_name               = "AWS Control Tower Account Factory"
  provisioning_artifact_name = "AWS Control Tower Account Factory"

  provisioning_parameters {
    key   = "AccountName"
    value = var.name
  }

  provisioning_parameters {
    key   = "AccountEmail"
    value = var.email
  }

  provisioning_parameters {
    key   = "ManagedOrganizationalUnit"
    value = "Custom (${var.ou_id})"
  }

  provisioning_parameters {
    key   = "SSOUserEmail"
    value = var.email
  }

  provisioning_parameters {
    key   = "SSOUserFirstName"
    value = var.sso_user_firstname
  }

  provisioning_parameters {
    key   = "SSOUserLastName"
    value = var.sso_user_lastname
  }

  tags = {
    ManagedBy  = "Terraform"
    OwningTeam = "Platform"
  }

  # Account creation can take 20-30 minutes
  timeouts {
    create = "60m"
    update = "60m"
    delete = "60m"
  }
}

# Deploy baseline IAM roles to the new account via StackSet
resource "aws_cloudformation_stack_set_instance" "deploy_baseline_roles" {
  stack_set_name = "BaselineIAMRoles"  # Pre-created StackSet

  deployment_targets {
    organizational_unit_ids = [var.parent_ou_id]
  }

  retain_stack = false
  region       = "eu-west-1"

  depends_on = [
    aws_servicecatalog_provisioned_product.account
  ]
}

Key Points About the Account Module

Service Catalog provisioning: The aws_servicecatalog_provisioned_product resource triggers Control Tower Account Factory. This:

  • Creates the AWS account
  • Enrolls it in Control Tower
  • Applies mandatory guardrails (SCPs)
  • Sets up CloudTrail and AWS Config
  • Creates the SSO user

The OU parameter format: Note "Custom (${var.ou_id})" – this is the exact format Control Tower expects. The “Custom” prefix indicates it’s a custom OU rather than a foundational one.

StackSet deployment: After the account exists, we deploy baseline IAM roles via a pre-existing CloudFormation StackSet. This runs automatically across all accounts in the OU.

Account ID lookup: We can’t get the account ID directly from Service Catalog, so we look it up after creation using the Organizations API.

modules/account/outputs.tf

output "account_id" {
  description = "The AWS account ID"
  value       = local.account_id
}

output "account_name" {
  description = "The account name"
  value       = var.name
}

output "deploy_role_arn" {
  description = "ARN of the deployment role in the new account"
  value       = local.account_id != null ? "arn:aws:iam::${local.account_id}:role/DeployRole" : null
}

modules/account/metadata.tf

We write account metadata to S3 for billing systems and asset management:

locals {
  # Infer deployment type from account name
  account_deployment_type = can(regex("data", var.name)) ? "Data" : "Containers"
  
  # Infer billing type from account name
  account_bill_type = can(regex("data", var.name)) ? "Data" : (
    can(regex("-prod", var.name)) ? "Prod" : "NonProd"
  )
  
  # Look up parent OU name for metadata
  parent_ou_name = [
    for ou in data.aws_organizations_organizational_units.root.children : 
    ou.name if ou.id == var.parent_ou_id
  ][0]
}

data "aws_organizations_organization" "org" {}

data "aws_organizations_organizational_units" "root" {
  parent_id = data.aws_organizations_organization.org.roots[0].id
}

resource "aws_s3_object" "account_metadata" {
  count = local.account_id != null ? 1 : 0

  bucket = "platform-account-metadata"  # Pre-existing bucket
  key    = "accounts/${var.name}-${local.account_id}.json"
  acl    = "private"
  
  content = jsonencode({
    account_id              = local.account_id
    account_name            = var.name
    alias                   = coalesce(var.alias, var.name)
    account_deployment_type = coalesce(var.account_deployment_type, local.account_deployment_type)
    account_bill_type       = coalesce(var.account_bill_type, local.account_bill_type)
    parent_ou_id            = var.parent_ou_id
    parent_ou_name          = local.parent_ou_name
    created_at              = timestamp()
  })

  lifecycle {
    ignore_changes = [content]  # Don't update timestamp on every apply
  }
}

This metadata feeds into:

  • Cost allocation and showback
  • Asset inventory
  • Compliance reporting
  • Automated tagging

The Stack Module

Each OU needs a CI/CD stack that can provision accounts within it:

modules/stack/main.tf

variable "name" {
  description = "Stack name"
  type        = string
}

variable "repository" {
  description = "Git repository name"
  type        = string
}

variable "path" {
  description = "Path within repository"
  type        = string
}

variable "deploy_role_arn" {
  description = "IAM role ARN for deployments"
  type        = string
}

# Create the CI/CD stack (example using a generic Terraform automation platform)
resource "cicd_stack" "this" {
  name = var.name

  vcs_config {
    branch     = "main"
    repository = var.repository
    path       = var.path
  }

  # Administrative stacks can create other stacks
  administrative = true

  labels = ["folder:platform/accounts"]
}

# Configure AWS credentials for the stack
resource "cicd_aws_integration" "this" {
  name     = var.name
  role_arn = var.deploy_role_arn

  # Account creation takes time, extend session duration
  session_duration_seconds = 3600
}

resource "cicd_aws_integration_attachment" "this" {
  stack_id       = cicd_stack.this.id
  integration_id = cicd_aws_integration.this.id
  read           = true
  write          = true
}

# Pass the role ARN as a Terraform variable
resource "cicd_environment_variable" "deploy_role" {
  stack_id = cicd_stack.this.id
  name     = "TF_VAR_deploy_role_arn"
  value    = var.deploy_role_arn
}

# Attach standard policies
resource "cicd_policy_attachment" "standard_plan" {
  policy_id = "standard-plan-policy"
  stack_id  = cicd_stack.this.id
}

resource "cicd_policy_attachment" "git_push_trigger" {
  policy_id = "git-push-trigger"
  stack_id  = cicd_stack.this.id
}

output "stack_id" {
  value = cicd_stack.this.id
}

ou/stacks.tf

Define a stack for each OU:

module "platform_ou" {
  source = "../modules/stack"

  name            = "platform-ou"
  repository      = "account-provisioning"
  path            = "ou/platform"
  deploy_role_arn = "arn:aws:iam::123456789012:role/AccountProvisioningRole"
}

module "infrastructure_ou" {
  source = "../modules/stack"

  name            = "infrastructure-ou"
  repository      = "account-provisioning"
  path            = "ou/infrastructure"
  deploy_role_arn = "arn:aws:iam::123456789012:role/AccountProvisioningRole"
}

module "sandbox_ou" {
  source = "../modules/stack"

  name            = "sandbox-ou"
  repository      = "account-provisioning"
  path            = "ou/sandbox"
  deploy_role_arn = "arn:aws:iam::123456789012:role/AccountProvisioningRole"
}

Creating Accounts

With the modules in place, creating an account is a simple Terraform definition.

Single Account

# ou/infrastructure/dns/main.tf
locals {
  account_name = "dns"
  parent_ou_id = "ou-xxxx-yyyyyyyy"  # Infrastructure OU
  prod_ou      = "ou-xxxx-prodprod"
  nonprod_ou   = "ou-xxxx-nonprodx"
}

module "dns_prod" {
  source = "../../../modules/account"

  name = "${local.account_name}-prod"

  parent_ou_id       = local.parent_ou_id
  ou_id              = local.prod_ou
  email              = "aws-accounts+infra-${local.account_name}-prod@example.com"
  sso_user_firstname = local.account_name
  sso_user_lastname  = "prod"
}

module "dns_nonprod" {
  source = "../../../modules/account"

  name = "${local.account_name}-nonprod"

  parent_ou_id       = local.parent_ou_id
  ou_id              = local.nonprod_ou
  email              = "aws-accounts+infra-${local.account_name}-nonprod@example.com"
  sso_user_firstname = local.account_name
  sso_user_lastname  = "nonprod"
}

Multiple Environments Pattern

For services that need the full environment set:

# ou/platform/my-service/main.tf
locals {
  service_name = "order-service"
  parent_ou_id = "ou-xxxx-platform"
  
  environments = {
    dev     = "ou-xxxx-devdevde"
    staging = "ou-xxxx-staging"
    prod    = "ou-xxxx-prodprod"
  }
}

module "accounts" {
  source   = "../../../modules/account"
  for_each = local.environments

  name = "${local.service_name}-${each.key}"

  parent_ou_id       = local.parent_ou_id
  ou_id              = each.value
  email              = "aws-accounts+platform-${local.service_name}-${each.key}@example.com"
  sso_user_firstname = local.service_name
  sso_user_lastname  = each.key
}

output "account_ids" {
  value = { for k, v in module.accounts : k => v.account_id }
}

The Baseline IAM StackSet

Before accounts can be provisioned, you need a StackSet that deploys baseline roles:

# baseline-iam-roles.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Baseline IAM roles for all accounts

Resources:
  DeployRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: DeployRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS: arn:aws:iam::123456789012:root  # Management account
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                sts:ExternalId: !Ref ExternalId
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AdministratorAccess
      Tags:
        - Key: ManagedBy
          Value: StackSet

  ReadOnlyRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: ReadOnlyRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS: arn:aws:iam::123456789012:root
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/ReadOnlyAccess

Parameters:
  ExternalId:
    Type: String
    Default: "platform-deploy-external-id"
    Description: External ID for assume role

Outputs:
  DeployRoleArn:
    Value: !GetAtt DeployRole.Arn
    Export:
      Name: DeployRoleArn

Create the StackSet in the management account:

aws cloudformation create-stack-set \
  --stack-set-name BaselineIAMRoles \
  --template-body file://baseline-iam-roles.yaml \
  --permission-model SERVICE_MANAGED \
  --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \
  --capabilities CAPABILITY_NAMED_IAM

Account Deletion

Deleting accounts requires careful sequencing:

1. Remove from Terraform

Delete the account module from the Terraform code and apply. This removes the Service Catalog provisioned product, which unenrolls the account from Control Tower but doesn’t delete it.

2. Move to Suspended OU

aws organizations move-account \
  --account-id 123456789012 \
  --source-parent-id ou-xxxx-current \
  --destination-parent-id ou-xxxx-suspended

3. Close the Account

Requires admin access in the management account:

aws organizations close-account --account-id 123456789012

4. Verify Suspension

aws organizations describe-account --account-id 123456789012 \
  --query 'Account.Status'
# Returns: "SUSPENDED"

The account remains in suspended state for 90 days before permanent deletion.

5. Clean Up SSO Assignments

Remove any SSO permission set assignments for the deleted account from your SSO configuration.

Gotchas and Lessons Learned

1. OUs Must Be Registered in Control Tower

Terraform-created OUs are not automatically registered with Control Tower. You must register them manually in the Control Tower console first, then reference them in Terraform.

2. Email Addresses Must Be Unique

AWS requires unique email addresses for each account. Use + addressing:

  • aws-accounts+service-prod@example.com
  • aws-accounts+service-dev@example.com

All emails route to the same mailbox but are unique to AWS.

3. Account Creation Takes Time

Service Catalog account provisioning typically takes 20-30 minutes. Set appropriate timeouts:

timeouts {
  create = "60m"
  update = "60m"
}

4. Account ID Lookup Timing

The account ID isn’t available until after creation completes. Use depends_on to sequence the lookup:

data "aws_organizations_organizational_unit_descendant_accounts" "accounts" {
  depends_on = [aws_servicecatalog_provisioned_product.account]
  parent_id  = var.parent_ou_id
}

5. StackSet Deployment is Eventually Consistent

New accounts may not immediately receive StackSet deployments. The StackSet targets the OU, and AWS eventually detects new accounts. Allow a few minutes after account creation.

6. Protect Account Emails with OPA/Sentinel

Once an account exists, changing its email is dangerous (it changes root access). Use policy-as-code to prevent email modifications:

# OPA policy to prevent account email changes
deny[msg] {
  input.resource_changes[_].type == "aws_servicecatalog_provisioned_product"
  input.resource_changes[_].change.actions[_] == "update"
  
  before := input.resource_changes[_].change.before.provisioning_parameters
  after := input.resource_changes[_].change.after.provisioning_parameters
  
  email_before := [p.value | p := before[_]; p.key == "AccountEmail"][0]
  email_after := [p.value | p := after[_]; p.key == "AccountEmail"][0]
  
  email_before != email_after
  
  msg := "Account email cannot be changed after creation"
}

7. Set Up Alerts for Root Login

Even with SSO, the root user still exists. Set up CloudWatch alerts for root login attempts:

resource "aws_cloudwatch_metric_alarm" "root_login" {
  alarm_name          = "root-account-login-${var.name}"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "RootAccountUsage"
  namespace           = "CloudTrailMetrics"
  period              = 300
  statistic           = "Sum"
  threshold           = 0
  alarm_description   = "Root account login detected"
  alarm_actions       = [aws_sns_topic.security_alerts.arn]
}

The End-to-End Flow

  1. Developer submits PR adding account definition to ou/platform/my-service/main.tf
  2. CI runs terraform plan showing new account resources
  3. PR approved and merged
  4. CI runs terraform apply:
    • Service Catalog triggers Account Factory
    • Account created and enrolled in Control Tower
    • StackSet deploys baseline IAM roles
    • SSO user created (invitation email sent)
    • Account metadata written to S3
  5. Developer receives SSO invitation and can access the new account
  6. Downstream pipelines can now deploy to the account using the provisioned IAM role

Total time: ~30 minutes from PR merge to usable account.

Summary

Building an account vending machine with Control Tower and Terraform gives you:

  • Self-service provisioning – developers request accounts via PR
  • Consistent configuration – every account gets baseline roles, guardrails, SSO
  • Audit trail – Git history shows who created what and when
  • Scalable – same process whether you have 10 or 1,000 accounts
  • Compliant – Control Tower guardrails enforced automatically

The upfront investment in building this pays off quickly when you’re managing accounts at scale.


Building an account vending machine or have questions about multi-account strategy? Find me on LinkedIn.

Found this helpful?

Comments