
Managing AWS Control Tower Member Accounts Nicely with Terraform and YAML (Without AFT)
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.
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.
# 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 {
# 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
])
}
# 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,
)
}
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"
}
}
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.
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.
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.
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.
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.
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...)
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.

