Control Tower’s console Account Factory is fine for a handful of accounts. The moment you need to vend dozens, attach a consistent baseline to every one, and treat that baseline as reviewed code, you want Account Factory for Terraform (AFT). AFT is an AWS-maintained framework that turns account creation into a GitOps workflow: you commit an account request, a pipeline calls Service Catalog to provision the account through Control Tower, and a chain of Terraform/Python customizations then bakes in tagging, networking, IAM, and guardrails — fully automated, fully auditable. This guide builds it end to end and covers the parts that actually break in production.
How AFT is wired: three account roles, four repos
AFT spans three account types and depends on four Git repositories. Get this mental model right before touching Terraform.
| Account | Role |
|---|---|
| Management | The Organizations root / Control Tower management account. AFT only touches it to call Service Catalog and read Control Tower state. You do not run the AFT pipelines here. |
| AFT management | A dedicated account that hosts AFT’s own infrastructure: the DynamoDB request tables, Step Functions state machines, CodePipeline/CodeBuild (or GitHub Actions runners), Lambda functions, and the AFT Terraform state. This is where the machinery lives. |
| Target (vended) accounts | The accounts AFT creates. Customizations run into these accounts via an assumed role. |
The four repos are the contract surface:
aft-account-request— one Terraform module invocation per account you want. This is the only repo most app teams ever touch.aft-global-customizations— Terraform/Python applied to every account AFT manages.aft-account-customizations— keyed by a customization name; an account opts in to exactly one.aft-account-provisioning-customizations— a Step Functions extension point that runs during vending, before the account is handed off.
Mental model: the request repo creates the account; the three customization repos shape it. Global runs first and everywhere, then the account-specific layer. State for each is isolated per account.
A vend flows roughly as: commit to aft-account-request -> AFT pipeline writes a row to the aft-request DynamoDB table -> a Step Functions state machine drives Service Catalog AWS Control Tower Account Factory -> Control Tower provisions the account -> provisioning customizations run -> global then account customizations run in the new account.
Step 1 — Prerequisites and the AFT management account
AFT assumes a working Control Tower landing zone already exists. Confirm it, then stand up the dedicated AFT management account (vend it through the console Account Factory once — bootstrapping AFT with AFT is a chicken-and-egg you avoid).
# Confirm Control Tower is deployed and note the home region
aws controltower list-landing-zones --region us-east-1
# Confirm the AFT management account exists in the org
aws organizations list-accounts \
--query "Accounts[?Name=='aft-management'].[Id,Email,Status]" \
--output table
You need Terraform >= 1.6 and a place to store the deployment module’s state (an S3 bucket + DynamoDB lock table you own, in the AFT management account). AFT manages its own internal state separately; this bucket is only for the bootstrap module itself.
Step 2 — Bootstrap AFT with the deployment module
AFT ships as the public module aws-ia/control_tower_account_factory/aws. You run it once, from a context that can assume roles into both the management and AFT management accounts. It builds everything: pipelines, tables, state machines, and the four repos’ backing infrastructure.
# main.tf — AFT deployment
terraform {
required_version = ">= 1.6.0"
backend "s3" {
bucket = "kv-aft-tfstate"
key = "aft/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "kv-aft-tflock"
encrypt = true
}
}
module "aft" {
source = "aws-ia/control_tower_account_factory/aws"
version = "1.14.0"
# Account wiring
ct_management_account_id = "111111111111"
log_archive_account_id = "222222222222"
audit_account_id = "333333333333"
aft_management_account_id = "444444444444"
# Regions
ct_home_region = "us-east-1"
tf_backend_secondary_region = "us-west-2"
# VCS backend — CodeCommit is the default; this example uses GitHub
vcs_provider = "github"
account_request_repo_name = "kloudvin/aft-account-request"
global_customizations_repo_name = "kloudvin/aft-global-customizations"
account_customizations_repo_name = "kloudvin/aft-account-customizations"
account_provisioning_customizations_repo_name = "kloudvin/aft-account-provisioning-customizations"
# Terraform distribution used by the pipelines inside vended accounts
terraform_distribution = "oss"
terraform_version = "1.6.6"
# Feature flags (see Step 6)
aft_feature_cloudtrail_data_events = true
aft_feature_enterprise_support = false
aft_feature_delete_default_vpcs_enabled = true
}
terraform init
terraform apply
GitHub vs. CodeCommit: with
vcs_provider = "github"(orgithub-enterprise/bitbucket/gitlab), AFT wires CodePipeline to your external repos via a CodeStar/CodeConnections connection — you must finish the connection handshake in the console and store the token in the AFT secret it provisions. Leavevcs_providerunset (CodeCommit) if you want the fully self-contained default; AFT then creates the four repos for you.
After apply, the AFT management account holds the request tables, Step Functions, and per-repo pipelines. Nothing is vended yet.
Step 3 — Author an account request
Each account is a module block in aft-account-request. The control_tower_parameters map is passed straight to Service Catalog; account_tags, custom_fields, and account_customizations_name drive AFT’s own logic.
# terraform/payments-prod.tf in aft-account-request
module "payments_prod" {
source = "./modules/aft-account-request"
control_tower_parameters = {
AccountEmail = "aws+payments-prod@kloudvin.io"
AccountName = "payments-prod"
ManagedOrganizationalUnit = "Workloads (ou-abcd-1234abcd)"
SSOUserEmail = "cloud-platform@kloudvin.io"
SSOUserFirstName = "Platform"
SSOUserLastName = "Team"
}
account_tags = {
"kv:cost-center" = "payments"
"kv:environment" = "prod"
"kv:data-class" = "pci"
}
change_management_parameters = {
change_requested_by = "platform-team"
change_reason = "stand up payments prod account"
}
custom_fields = {
network_zone = "restricted"
}
account_customizations_name = "pci-workload"
}
Commit and push. The aft-account-request pipeline runs terraform apply, which writes/updates the row in the aft-request DynamoDB table; a DynamoDB stream triggers the provisioning Step Functions state machine, which invokes Service Catalog. To close an account, you remove its module block (see Step 7) — AFT does not delete an account merely because the file changed unless you opt into that behavior.
Step 4 — The three customization layers
This is where AFT earns its keep. Every vended account runs global customizations, then its named account customizations. Each layer is a directory with optional pre-api-helpers.sh, a terraform/ folder, api_helpers/, and post-api-helpers.sh.
Global customizations apply to all accounts — the baseline you never want drifting:
# aft-global-customizations/terraform/baseline.tf
# Default region is injected by AFT; this provider already targets the vended account.
resource "aws_iam_account_password_policy" "strict" {
minimum_password_length = 14
require_symbols = true
require_numbers = true
require_uppercase_characters = true
require_lowercase_characters = true
max_password_age = 90
password_reuse_prevention = 24
allow_users_to_change_password = true
}
resource "aws_ebs_encryption_by_default" "this" {
enabled = true
}
Account customizations are keyed by directory name under the repo root. The account_customizations_name = "pci-workload" in the request maps to aft-account-customizations/pci-workload/. An account gets exactly one named set, so model your tiers (sandbox, standard-workload, pci-workload) as directories:
aft-account-customizations/
pci-workload/
terraform/
vpc.tf
config-rules.tf
api_helpers/
pre-api-helpers.sh
post-api-helpers.sh
Layering rule: keep org-wide invariants (encryption defaults, password policy, mandatory tags) in global, and tier-specific posture (network topology, Config conformance packs, stricter SCABs) in account customizations. Resist the urge to branch global on account tags — that’s what the named layer is for.
Step 5 — Provisioning customizations and pre/post-API hooks
There are two distinct hook surfaces, and people conflate them.
Account provisioning customizations run inside the Step Functions vend flow, before the account is fully handed off. They’re an aws-ia/.../identify_targets-style state-machine pass-through: you supply a Python/Lambda step name and AFT invokes it during provisioning. Use this for things that must exist before any customization Terraform runs — e.g., registering the account with an IPAM pool or seeding a delegated-admin association.
# aft-account-provisioning-customizations/example/lambda_function.py
def lambda_handler(event, context):
# 'event' carries account_request + control_tower_parameters
account_id = event["account_info"]["account"]["id"]
# ... call your IPAM/registration API here ...
# Return the event so the state machine continues the chain.
return event
Pre-API and post-API helpers are the pre-api-helpers.sh / post-api-helpers.sh scripts inside global and account customizations. They run on the CodeBuild host around the terraform apply of that layer. pre-api-helpers.sh is the place to enable an account-level service before Terraform needs it; post-api-helpers.sh handles anything Terraform can’t cleanly express.
#!/bin/bash
# pre-api-helpers.sh — runs BEFORE terraform apply for this layer
set -e
# AFT exports VENDED_ACCOUNT_ID and the assumed-role creds for the target account.
# Enable Security Hub before our Config rules reference it.
aws securityhub enable-security-hub \
--enable-default-standards \
--region "$AWS_REGION" || echo "Security Hub already enabled"
Idempotency is non-negotiable. Every customization re-runs on every fleet-wide pass (Step 7). Helpers must tolerate “already enabled / already exists” without failing the build. Guard API calls with
|| trueor explicit describe-then-act logic.
Step 6 — State, providers, and feature flags
AFT keeps isolated Terraform state per account, per customization layer, in S3 in the AFT management account, locked with DynamoDB. You never share state across accounts — that isolation is what makes fleet operations safe. Pin your provider and Terraform versions deliberately; a provider bump applied across the whole fleet at once is a real blast radius.
# aft-providers.jinja is rendered by AFT, but you control versions here:
# aft-global-customizations/terraform/versions.tf
terraform {
required_version = ">= 1.6.0, < 1.8.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}
Key feature flags on the deployment module, with what they actually do:
| Flag | Effect |
|---|---|
aft_feature_cloudtrail_data_events |
Enables CloudTrail S3 data-event logging for the AFT pipelines themselves. |
aft_feature_delete_default_vpcs_enabled |
AFT deletes the default VPC in every region of each vended account during provisioning. |
aft_feature_enterprise_support |
Auto-enrolls vended accounts into AWS Enterprise Support (only if your org has the plan). |
terraform_distribution |
oss, tfc (Terraform Cloud), or tfe — where the customization terraform apply actually executes. |
If you set terraform_distribution = "tfc", AFT drives runs through Terraform Cloud workspaces instead of CodeBuild — useful if Sentinel policy-as-code gating is a hard requirement on every account’s Terraform. Otherwise oss (CodeBuild-local Terraform) is the simplest and what most teams ship.
Step 7 — Day-two operations
The whole point of AFT is that day-two is also GitOps.
Re-run customizations fleet-wide. When you change global customizations, you want every account to pick them up. AFT ships a Lambda, aft-invoke-customizations, that fans the customization pipeline out across accounts. Invoke it with an empty/null payload to target all managed accounts, or a list to scope it:
aws lambda invoke \
--function-name aft-invoke-customizations \
--payload '{"include": [{"type": "all"}]}' \
--cli-binary-format raw-in-base64-out \
--region us-east-1 response.json
Drift handling. Because each account’s state is isolated, drift is detected the same way as any Terraform: re-run the customization pipeline and read the plan. Treat the customization repos as the source of truth and let the next apply reconcile. Don’t terraform import manually in target accounts — you’ll desync AFT’s managed state.
Closing / decommissioning an account. Remove the module block from aft-account-request and apply. By default Control Tower / Organizations does not auto-delete the account; AFT removes its request record and stops managing it. Final closure of the AWS account (the 90-day suspension flow) is still an Organizations action you perform deliberately — by design, so a deleted Terraform file can’t nuke a production account.
Step 8 — Troubleshooting failed vends
When an account doesn’t appear, walk the pipeline in order. The failure is almost always observable in one of three places.
- Step Functions execution trace. The provisioning state machine in the AFT management account is your primary signal. A failed Service Catalog provision shows the exact state and error.
SM_ARN=$(aws stepfunctions list-state-machines \
--query "stateMachines[?contains(name,'aft-account-provisioning-framework')].stateMachineArn" \
--output text --region us-east-1)
aws stepfunctions list-executions \
--state-machine-arn "$SM_ARN" --status-filter FAILED \
--region us-east-1
- DynamoDB request tables. The
aft-requesttable holds the desired state;aft-request-metadatarecords progress per account. A row stuck without a corresponding account usually means Service Catalog rejected the request (bad OU name, email already in use, SSO user conflict).
aws dynamodb scan --table-name aft-request-metadata \
--filter-expression "account_status <> :s" \
--expression-attribute-values '{":s":{"S":"COMPLETED"}}' \
--region us-east-1
- Service Catalog provisioned product. The actual Control Tower call. A
TAINTEDorERRORprovisioned product, viewed in the management account’s Service Catalog, gives the underlying Control Tower error verbatim — most often a non-unique account email or an OU that isn’t registered with Control Tower.
Rollback pattern. A failed customization (not provisioning) leaves a real account with a half-applied baseline. Fix the customization code and re-run the customization pipeline for that single account; the isolated state makes re-apply safe and convergent. A failed provisioning before the account exists is safe to retry by re-triggering the request pipeline once the root cause (email/OU/SSO) is corrected — AFT is idempotent on the request key.
Verify
Confirm the foundation and a real vend end to end:
# 1. AFT machinery is present in the AFT management account
aws dynamodb list-tables --region us-east-1 \
--query "TableNames[?starts_with(@,'aft-')]"
# 2. The four pipelines exist
aws codepipeline list-pipelines --region us-east-1 \
--query "pipelines[?contains(name,'aft')].name"
# 3. A vended account landed in the right OU
aws organizations list-accounts-for-parent \
--parent-id ou-abcd-1234abcd \
--query "Accounts[?Name=='payments-prod'].[Id,Status]" --output table
# 4. Customizations applied — check a global baseline in the target account
# (assume the AFT execution role into the vended account first)
aws ec2 get-ebs-encryption-by-default --region us-east-1
A clean run shows: tables present, four pipelines, the account ACTIVE under the intended OU, and EBS default encryption returning true — proof the global customization layer reached the new account.
Enterprise scenario
A payments platform team running ~140 accounts hit a hard PCI-DSS control: every account must delete its default VPC in all 17 enabled regions before any workload Terraform runs, and auditors wanted evidence it happened during provisioning, not after. Their original setup deleted default VPCs in a post-API helper, which auditors flagged because there was a window where the account existed with default VPCs present.
The fix was to push it earlier and make it native. They turned on aft_feature_delete_default_vpcs_enabled = true so AFT removes default VPCs as part of the provisioning framework itself, then used the account-provisioning customization Lambda to emit a verification record into a central DynamoDB evidence table keyed by account ID and timestamp — produced inside the vend flow, before hand-off.
# aft-account-provisioning-customizations: emit PCI evidence during vend
import boto3, time
def lambda_handler(event, context):
acct = event["account_info"]["account"]["id"]
boto3.client("dynamodb").put_item(
TableName="pci-vend-evidence",
Item={
"account_id": {"S": acct},
"control": {"S": "default-vpc-deleted-all-regions"},
"vended_at": {"S": str(int(time.time()))},
},
)
return event # continue the state machine
Result: the control executes inside the audited Step Functions trace, the evidence row is generated by the same flow, and there is no longer a post-provisioning gap. The auditors accepted the Step Functions execution history plus the evidence table as proof — and the team stopped maintaining the brittle post-API script entirely.