Managing AWS Control Tower Member Accounts Nicely with Terraform and YAML (Without AFT)

Managing AWS Control Tower Member Accounts Nicely with Terraform and YAML (Without AFT)

I tried managing Control Tower member accounts defined in YAML with Terraform, without using AFT. Recommended for those who want to manage member accounts with Terraform but find AFT a bit much.
2026.10.12

This page has been translated by machine translation. View original

Introduction

Hello everyone, this is Akaike.
Have you ever wanted to manage Control Tower member accounts with Terraform without using AFT (Account Factory for Terraform)? I have.

First, for how to issue accounts via Service Catalog without using AFT, the following article is helpful.

https://dev.classmethod.jp/articles/create-ct-member-account-by-terraform-not-aft/

This time, based on that, I tried defining accounts in YAML and having Terraform read and create them.

Prerequisites

  • AWS Control Tower landing zone must be set up
  • The destination OU must be registered with Control Tower
  • The principal executing Terraform must be granted access to the portfolio

Architecture Overview

The configuration defines accounts with one file per account, and Terraform reads them all and launches the account factory for each account.

Source Code

The directory structure is as follows.

.
├── accounts/              # Account definitions
│   ├── example-dev.yaml
│   └── example-prd.yaml
├── accounts.tf            # Account factory launch
├── organizations.tf       # Reference OU ID from OU path
├── locals.tf              # YAML loading and parameter assembly
└── providers.tf

The code is as follows.

accounts/example-dev.yaml
# Parameters passed to the Service Catalog product "AWS Control Tower Account Factory"
# Key names are the same as the input fields in the Service Catalog console
provisioning_parameters:
  AccountName: Example-Dev
  AccountEmail: aws+example-dev@example.com
  ManagedOrganizationalUnit: Workloads/Dev
  SSOUserEmail: aws+example-dev@example.com
  SSOUserFirstName: AWS
  SSOUserLastName: Admin

# Tags attached to the provisioned product
tags:
  created-by: terraform
  system: example
  environment: development
locals.tf
locals {
  # Product ID of the Account Factory in the Control Tower home region
  #   aws servicecatalog describe-product --name "AWS Control Tower Account Factory" \
  #     --region ap-northeast-1 --query 'ProductViewSummary.ProductId' --output text
  account_factory_product_id = "prod-xxxxxxxxxxxxx"

  # Read YAMLs under accounts/ and use the filename without extension as the key
  accounts = {
    for filename in fileset("${path.module}/accounts", "*.yaml") :
    trimsuffix(filename, ".yaml") => yamldecode(file("${path.module}/accounts/${filename}"))
  }

  # ManagedOrganizationalUnit must be passed in the format "OU name (OU ID)"
  # The OU name is not a path but the name of the destination OU itself, so use the last segment of the path
  # All other parameters are passed directly from the YAML to the product
  provisioning_parameters = {
    for key, account in local.accounts : key => merge(
      account.provisioning_parameters,
      {
        ManagedOrganizationalUnit = format(
          "%s (%s)",
          reverse(split("/", account.provisioning_parameters.ManagedOrganizationalUnit))[0],
          local.ou_ids[account.provisioning_parameters.ManagedOrganizationalUnit],
        )
      }
    )
  }

  # Since the ID changes when the landing zone is updated, dynamically reference the active version
  active_provisioning_artifact_id = one([
    for artifact in data.aws_servicecatalog_provisioning_artifacts.account_factory.provisioning_artifact_details :
    artifact.id if artifact.active
  ])
}
organizations.tf
# OUs can be nested up to 5 levels under Root
# https://docs.aws.amazon.com/organizations/latest/userguide/orgs_reference_limits.html
#
# Since aws_organizations_organizational_units only returns direct children of parent_id,
# chain data sources for 5 levels from Root to cover all OUs

data "aws_organizations_organization" "this" {}

data "aws_organizations_organizational_units" "level1" {
  parent_id = data.aws_organizations_organization.this.roots[0].id
}

data "aws_organizations_organizational_units" "level2" {
  for_each = local.ou_level1

  parent_id = each.value
}

## Omitted (level3 through level5 follow the same pattern, passing the map of the level above to for_each)

locals {
  # Map of "OU path => OU ID" for each level
  # The for_each key of the next level down becomes the parent path, so concatenating them forms the full path
  ou_level1 = {
    for ou in data.aws_organizations_organizational_units.level1.children : ou.name => ou.id
  }

  ou_level2 = merge([
    for parent, ous in data.aws_organizations_organizational_units.level2 : {
      for ou in ous.children : "${parent}/${ou.name}" => ou.id
    }
  ]...)

  ## Omitted (ou_level3 through ou_level5 follow the same pattern)

  # Map for looking up OU IDs from OU designations
  # Nested OUs may have duplicate names, so use the full path from Root as the key
  ou_ids = merge(
    local.ou_level1,
    local.ou_level2,
    local.ou_level3,
    local.ou_level4,
    local.ou_level5,
  )
}
accounts.tf
data "aws_servicecatalog_provisioning_artifacts" "account_factory" {
  product_id = local.account_factory_product_id
}

resource "aws_servicecatalog_provisioned_product" "account" {
  for_each = local.accounts

  name                     = each.value.provisioning_parameters.AccountName
  product_id               = local.account_factory_product_id
  provisioning_artifact_id = local.active_provisioning_artifact_id

  dynamic "provisioning_parameters" {
    for_each = local.provisioning_parameters[each.key]

    content {
      key   = provisioning_parameters.key
      value = provisioning_parameters.value
    }
  }

  tags = each.value.tags

  # Extend timeout since the default 30 minutes may not be sufficient
  timeouts {
    create = "60m"
    update = "60m"
    delete = "60m"
  }
}
providers.tf
terraform {
  required_version = "~> 1.16"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.68"
    }
  }
}

provider "aws" {
  region = "ap-northeast-1"
}

Implementation Details

Split YAML Files Per Account

Account definitions are placed one file at a time under accounts/ and enumerated using fileset().
The filename without the extension becomes the for_each key.

The reason for not consolidating everything into a single YAML is to avoid editing existing files when adding an account. This keeps Pull Request diffs to just one new file, which is nice because simultaneous provisioning requests won't cause conflicts.

For how to use YAML and similar files as configuration in Terraform, please refer to the following if you're interested.

https://dev.classmethod.jp/articles/terraform-yaml-toml-config/

Looking Up OU IDs

The value passed to ManagedOrganizationalUnit must be in the format OU name (OU ID).
However, writing the OU ID directly in the YAML is hard to read, so the OU ID is looked up from the Organizations data source based on the OU name.

https://docs.aws.amazon.com/controltower/latest/userguide/automated-provisioning-walkthrough.html

Since aws_organizations_organizational_units only returns direct children of parent_id, data sources are chained for 5 levels from Root.
Since there is a limit of 5 levels of OU nesting under Root, this covers all levels.

https://docs.aws.amazon.com/ja_jp/organizations/latest/userguide/orgs_reference_limits.html

Dynamically Retrieving the Active Product Version

Since provisioning_artifact_id changes when the landing zone is updated, the active one is selected from the data source.

Verification

Before running, look up the product ID to set in account_factory_product_id in locals.tf.
Since the account factory is actually a Service Catalog product called AWS Control Tower Account Factory, retrieve the product ID in the Control Tower home region.

aws servicecatalog describe-product \
  --name "AWS Control Tower Account Factory" \
  --region ap-northeast-1 \
  --query 'ProductViewSummary.ProductId' \
  --output text
prod-xxxxxxxxxxxxx

After setting the value in locals.tf, run terraform plan.

terraform plan

Terraform will perform the following actions:

  # aws_servicecatalog_provisioned_product.account["example-dev"] will be created
  + resource "aws_servicecatalog_provisioned_product" "account" {
      + accept_language                        = "en"
      + arn                                    = (known after apply)
      + id                                     = (known after apply)
      + ignore_errors                          = false
      + name                                   = "Example-Dev"
      + outputs                                = (known after apply)
      + path_id                                = (known after apply)
      + product_id                             = "prod-xxxxxxxxxxxxx"
      + provisioning_artifact_id               = "pa-xxxxxxxxxxxxx"
      + region                                 = "ap-northeast-1"
      + retain_physical_resources              = false
      + status                                 = (known after apply)
      + tags                                   = {
          + "created-by"  = "terraform"
          + "environment" = "development"
          + "system"      = "example"
        }

      + provisioning_parameters {
          + key   = "AccountEmail"
          + value = "aws+example-dev@example.com"
        }
      + provisioning_parameters {
          + key   = "AccountName"
          + value = "Example-Dev"
        }
      + provisioning_parameters {
          + key   = "ManagedOrganizationalUnit"
          + value = "Dev (ou-xxxx-xxxxxxxx)"
        }
      + provisioning_parameters {
          + key   = "SSOUserEmail"
          + value = "aws+example-dev@example.com"
        }
      + provisioning_parameters {
          + key   = "SSOUserFirstName"
          + value = "AWS"
        }
      + provisioning_parameters {
          + key   = "SSOUserLastName"
          + value = "Admin"
        }

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

## Omitted (example-prd is the same)

Plan: 2 to add, 0 to change, 0 to destroy.

If there are no issues, run terraform apply.

terraform apply

Deployment takes about 10 to 15 minutes to complete.
Once complete, the status of the provisioned product becomes AVAILABLE, and the created account ID can be retrieved from outputs.

terraform state show 'aws_servicecatalog_provisioned_product.account["example-dev"]'

    status = "AVAILABLE"
    type   = "CONTROL_TOWER_ACCOUNT"
    outputs = [
      {
        key   = "AccountId"
        value = "XXXXXXXXXXXX"
      },
      ## Omitted
    ]

Going forward, when issuing an account, simply place one YAML file and run apply.

Since no diff appears in existing files or accounts, it's great that account provisioning requests can be made directly in the form of Pull Requests.

Considerations

Up to 5 Accounts Can Be Created at Once

Control Tower can provision up to 5 accounts simultaneously.

https://docs.aws.amazon.com/controltower/latest/userguide/provision-as-end-user.html

Since Terraform's default parallelism is 10, applying 6 or more accounts at once may hit the limit.
Therefore, it is safer to limit parallelism with terraform apply -parallelism=5.

Also, the default creation timeout for aws_servicecatalog_provisioned_product is 30 minutes.
Reducing parallelism means subsequent accounts will have to wait, so this time we've extended it to 60 minutes using timeouts just to be safe.

https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/servicecatalog_provisioned_product

AccountName Cannot Be Changed Later

The name of aws_servicecatalog_provisioned_product is a ForceNew attribute that triggers recreation when changed. With this configuration, changing AccountName in the YAML will cause Terraform to delete and recreate the provisioned product.

Therefore, it is safer to operate with the policy of not changing AccountName after the fact.

Deleting a Resource Removes It from Control Tower Management

Deleting aws_servicecatalog_provisioned_product does not delete the AWS account itself.
When I actually tested it, the account was removed from Control Tower management and moved from its destination OU to directly under Root.

Therefore, if you want to close an account, you need to separately perform a closure operation from Organizations.

TagOptions and Notifications Are Not Configured

The Control Tower documentation states that setting TagOptions or notifications when launching the account factory may cause provisioning to fail. (Scary...)

https://docs.aws.amazon.com/controltower/latest/userguide/provision-as-end-user.html

I haven't verified whether it actually fails, but in this case I've configured only tags without specifying notification_arns.

Conclusion

That's how to manage AWS Control Tower member accounts nicely with Terraform and YAML without AFT.

AFT is convenient, but it requires deploying various resources and preparing repositories, and at a scale of a few to a few dozen accounts, it can feel like overkill.
In such cases, this configuration only requires adding a few files to an existing Terraform repository, and having account definitions remain as YAML files is also easy to understand operationally and is recommended.

On the other hand, if you want to go as far as applying baselines and customizations after account creation, I think it's better to straightforwardly consider AFT.

Since this depends on scale and requirements, it might be fine to start small and switch to AFT when things get out of hand.


そのマルチアカウント運用、気合いで支えていませんか

Organizations や Control Tower で土台は作れても、アカウントもポリシーも増えるほど、運用は「詳しい一人」に寄りかかっていく。属人化が限界を迎える前に、組織として回す仕組み=CCoEへ。5,600社の支援から得た立ち上げの型を、無料資料にまとめました。

CCoE総合支援

組織で回す仕組みの資料をもらう

Share this article

AWSのお困り事はクラスメソッドへ