Implementation Guide
Turn account creation into a git push: building AWS Account Factory for Terraform, step by step
Stand up AFT once and every new AWS account becomes a reviewed Terraform file and a git push — provisioned, baselined, and customized without anyone touching a console.
Scope & versions. Verified against the aws-ia/terraform-aws-control_tower_account_factory module — latest release 1.20.1 (2026-05-20), Terraform ≥ 1.6.1. Repo and variable names verified against the module's main branch. Re-verify the module version, variable names, and feature flags before you apply — this framework moves.
The account that starts as a ticket — and the git push that replaces it
You have Control Tower, and every new account still starts as a ticket: someone opens Account Factory in the console, fills in the form, then hand-applies the same baseline — a VPC tweak, an IAM role, a logging config — one account at a time. That holds up until you're vending accounts by the dozen and no two look alike. AFT swaps the clicking for a pipeline: an account is a Terraform file, a git push provisions it, and the same customizations land on every account automatically. This guide stands the whole thing up in seven phases — the real module inputs, a verbatim account request, and the black-box gotchas nobody warns you about.
- AFT is account bootstrapping on top of Control Tower — not app infrastructure. AWS says so explicitly: the pipeline is "not intended for use in deploying resources… that your accounts require to run your applications." Use it to vend and baseline accounts, not to deploy EC2 or run workloads.
- One Terraform module builds the whole control plane. You
terraform applyit once, authenticated to the Control Tower management account with AdministratorAccess — and it stands up the pipelines / Step Functions / DynamoDB / Lambda that then operate in a separate AFT management account. - Four git repos are the interface.
aft-account-requestvends accounts;aft-global-customizationsbaselines all of them;aft-account-customizationstailors specific ones;aft-account-provisioning-customizationsinjects steps mid-provision. After setup, account lifecycle is agit push. - Pin the version and own your state. Set
aft_framework_repo_git_refto a release tag (e.g.1.20.1) instead of trackingmain, and stand up your own S3+DynamoDB backend — AFT does not manage one. - It's powerful but a black box. No console, no dashboards; you can only apply from the default branch; accounts vend in batches of 5; and the Step Function reports invoke success, not pipeline success — so failed builds can look green. Budget 1–2 weeks and an experienced team.
The finished machine — what you're building, and what you need first
The end state: a Control Tower management account you run Terraform from, a dedicated AFT management account the machinery lives in, and four repos that drive everything. A push to the request repo travels a request pipeline, Control Tower vends the account, then a per-account pipeline applies your global baseline followed by any account-specific work. Every later figure lights up one more piece of this same canvas.
Prerequisites — have these before you start
| You need | Why |
|---|---|
| A working Control Tower landing zone | AFT governs accounts through Control Tower; it doesn't replace it. Hard prerequisite — note your CT home Region. |
| Control Tower management account + an admin role | You deploy the module authenticated as this account; the role needs AdministratorAccess and STS creds with a ≥ 60-minute timeout. |
| A dedicated AFT management account (≠ CT management account) | The framework lives here, separate from the CT management account. Provision it via Control Tower into its own OU (up to 30 min). |
| The Log Archive and Audit account IDs | Required module inputs; created by Control Tower during landing-zone setup. |
| Terraform ≥ 1.6.1 and a state backend | Runs the module; AFT does not manage your backend. Community Edition, HCP Terraform, or Enterprise all work. |
| A VCS provider via CodeConnections | Hosts the four repos. CodeCommit by default; GitHub / GitLab / Bitbucket / Azure DevOps via CodeConnections. |
| An OU for AFT | AWS strongly recommends a separate OU to contain the framework's blast radius. |
Phase 1 — Lay the ground: landing zone, a dedicated AFT account, a Git provider
Goal: a Control Tower landing zone with a separate OU holding a freshly-provisioned, empty AFT management account, plus a VCS ready to host the four repos. No AFT machinery yet — that comes next.
From the Control Tower management account, do three things in order: (1) create a separate OU for AFT; (2) provision the AFT management account into that OU from Control Tower's Create account flow (up to 30 minutes); (3) create the four empty repos in your VCS — aft-account-request, aft-global-customizations, aft-account-customizations, aft-account-provisioning-customizations (you populate them in Phase 3).
Phase 2 — Deploy the framework: one module, run from the Control Tower management account
Goal: a single terraform apply stands up the entire AFT control plane inside the AFT management account. The whole GitHub repo is the module — reference it, don't clone, so you can consume updates. Run the apply as the Control Tower management account admin role; the module then builds everything into the AFT account via cross-account roles.
module "aft" {
# Pin to a release tag in production — do not track main.
source = "github.com/aws-ia/terraform-aws-control_tower_account_factory"
# --- Core account wiring (all four are required, 12-digit IDs) ---
ct_management_account_id = "111111111111"
log_archive_account_id = "222222222222"
audit_account_id = "333333333333"
aft_management_account_id = "444444444444"
# MUST equal your Control Tower home Region
ct_home_region = "us-east-1"
tf_backend_secondary_region = "us-west-2"
# --- Version pin: lock the framework to a release ---
aft_framework_repo_git_ref = "1.20.1"
# --- VCS: default is codecommit; here we use GitHub via CodeConnections ---
vcs_provider = "github"
account_request_repo_name = "my-org/aft-account-request"
global_customizations_repo_name = "my-org/aft-global-customizations"
account_customizations_repo_name = "my-org/aft-account-customizations"
account_provisioning_customizations_repo_name = "my-org/aft-account-provisioning-customizations"
# --- Feature options (all default false) ---
aft_feature_cloudtrail_data_events = false
aft_feature_enterprise_support = false
aft_feature_delete_default_vpcs_enabled = true
}
# Authenticated as the CT management account admin role:
terraform init
terraform apply # 20–30 minutes; use STS creds with a ≥ 60-minute timeout
# In the AFT management account, confirm the machinery exists:
aws codepipeline get-pipeline --name ct-aft-account-request
aws dynamodb describe-table --table-name aft-request-metadata
aws stepfunctions list-state-machines --query "stateMachines[].name"
What it builds: the request CodePipeline (ct-aft-account-request), CodeBuild projects, the account-provisioning-framework and aft-invoke-customizations Step Functions, the aft-request-metadata DynamoDB table, the aft-account-request-processor Lambda, an SQS FIFO dead-letter queue, and the aft-notifications / aft-failure-notifications SNS topics.
main with terraform get -update — fine for a lab, but tracking main lets an unrelated commit change your account pipeline. Set aft_framework_repo_git_ref to a release tag (here, 1.20.1) and bump it deliberately. And since AFT doesn't manage your backend, your preserved state file is the only rollback net: keep it in S3 with DynamoDB locking from the first apply, and treat it as sensitive — it can hold SSH keys and Terraform tokens in plain text.Phase 3 — Wire up the four repos: confirm the connection, push the templates
Goal: the framework can actually read your repos, and each repo holds the directory structure AFT expects.
Step 1 — confirm the connection. For an external VCS, AFT initiates a CodeConnections (CodeStar) connection in Pending; approve it in the AFT management account console. This manual gate is the one people forget. Step 2 — populate each repo from the templates AWS ships under sources/aft-customizations-repos/, then grant the AWSAFTExecution role access to the Account Factory Service Catalog portfolio. Each repo has a required layout:
aft-account-request/ # one .tf file per account you vend (Phase 4)
aft-global-customizations/
├── api_helpers/ # bash helpers
│ └── python/ # python helpers
└── terraform/ # applied to EVERY account
aft-account-customizations/
└── ACCOUNT_TEMPLATE/ # copy per named customization set
aft-account-provisioning-customizations/
├── customizations.asl.json # optional Step Functions definition
└── *.tf # its backing Terraform
Phase 4 — Vend your first account: one file, one push
Goal: a real account, created and enrolled, from one committed Terraform file. An account request is just an ordinary module call in aft-account-request; the push invokes the ct-aft-account-request pipeline, which writes a request item to DynamoDB, and Control Tower vends the account through Service Catalog.
This is the AWS-provided example, verbatim. Commit it, push, and the request pipeline runs:
module "sandbox_account_01" {
source = "./modules/aft-account-request"
control_tower_parameters = {
AccountEmail = "john.doe@amazon.com"
AccountName = "sandbox-account-01"
# Syntax for top-level OU
ManagedOrganizationalUnit = "Sandbox"
# Syntax for nested OU
# ManagedOrganizationalUnit = "Sandbox (ou-xfe5-a8hb8ml8)"
SSOUserEmail = "john.doe@amazon.com"
SSOUserFirstName = "John"
SSOUserLastName = "Doe"
}
account_tags = {
"ABC:Owner" = "john.doe@amazon.com"
"ABC:Division" = "ENT"
"ABC:Environment" = "Dev"
"ABC:CostCenter" = "123456"
"ABC:Vended" = "true"
"ABC:DivCode" = "102"
"ABC:BUCode" = "ABC003"
"ABC:Project" = "123456"
}
change_management_parameters = {
change_requested_by = "John Doe"
change_reason = "testing the account vending process"
}
custom_fields = {
custom1 = "a"
custom2 = "b"
}
account_customizations_name = "sandbox-customizations"
}
git add sandbox-account-01.tf
git commit -m "Vend sandbox-account-01"
git push # invokes the ct-aft-account-request pipeline
# Request queued and metadata written:
aws codepipeline get-pipeline-state --name ct-aft-account-request
aws dynamodb scan --table-name aft-request-metadata --max-items 5
# Then confirm the account shows Enrolled in Control Tower.
What each block does: control_tower_parameters is the account's identity — email, name, OU, SSO user — and cannot change after provisioning, so get it right first time. account_tags are business tags; change_management_parameters record change_reason / change_requested_by for the audit trail. custom_fields land as SSM parameters under /aft/account-request/custom-fields/ in the vended account, so customizations can branch on them, and account_customizations_name names the folder in aft-account-customizations to apply (Phase 6).
| # | Where | What happens |
|---|---|---|
| 1 | repo-request | The git push of the account-request .tf triggers the ct-aft-account-request pipeline. |
| 2 | request-pipeline | CodePipeline + CodeBuild process the request and hand it to the processor Lambda. |
| 3 | lambda / ddb | The request is written to the aft-request-metadata table (FIFO); malformed or racing requests land in aft-account-request-dlq.fifo. |
| 4 | ct-mgmt | Control Tower vends the account through Service Catalog — this stage runs in the CT management account. |
| 5 | sfn-prov | Provisioning framework: validate → store metadata → create the AWSAFTExecution role → apply tags → apply feature options. |
| 6 | per-acct-pipeline | AFT creates one customization pipeline for the account and invokes it. |
| 7 | aftexec-role | The pipeline assumes AWSAFTExecution and applies global then account customizations into the vended account. |
| 8 | sns | Success and failure both post to aft-notifications / aft-failure-notifications. |
ConditionalCheckFailedException. (2) The target OU must already exist, and a nested OU must use the OUName (OU-ID) form. Terraform here manages a DynamoDB item, not the account directly — so if you later move the account's OU by hand, AFT won't see it as drift.That's a working account factory: a git push vends and enrolls an account. The rest — baseline customizations, per-account tailoring, mid-provision hooks, and day-2 — is how you make the accounts actually useful and keep the thing running.
Phase 5 — Bake in the baseline: customizations every account inherits
Goal: every account AFT vends gets the same baseline automatically. Global customizations live in aft-global-customizations and run automatically, after the provisioning framework and before per-account work.
AWSAFTExecution role. On re-invoke, AFT processes 5 accounts at a time.Layout by type: Terraform → terraform/, Python → api_helpers/python/, bash → api_helpers/. AFT renders each target account's provider and backend from Jinja templates it ships (aft-providers.jinja, backend.jinja), so your terraform/ folder carries only the resources, not the provider plumbing.
# aft-global-customizations/terraform/baseline.tf
resource "aws_iam_role" "org_readonly" {
name = "org-readonly-baseline"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.ct_management_account_id}:root" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "org_readonly" {
role = aws_iam_role.org_readonly.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
org-readonly-baseline role exists in the new account. Customizations are declarative Terraform — remove the resource and re-invoke to converge. But a push does nothing on its own for existing accounts (re-invoke, below), and because you can only apply from the default branch, a broken baseline hits all new accounts — test it against one throwaway account first.Phase 6 — Tailor a single account: per-account customizations
Goal: give one account (or a set) something the others don't, keyed by its request's account_customizations_name. The link is by folder name: in aft-account-customizations, copy the provided ACCOUNT_TEMPLATE to a folder named exactly like that value — sandbox-customizations here. The per-account pipeline runs the account stage after the global stage, so account work layers on top of the baseline (the same customize arc as Figure 6).
aft-account-customizations/
└── sandbox-customizations/ # matches account_customizations_name
├── api_helpers/
│ ├── pre-api-helpers.sh
│ ├── post-api-helpers.sh
│ └── python/
└── terraform/
└── sandbox-extras.tf # resources only sandbox accounts get
Different accounts point at different folders; several accounts can share one folder.
Phase 7 — (Advanced) Inject steps mid-provision: the provisioning-framework state machine
Goal: run custom, non-Terraform work during provisioning — before any customization stage — for every account. Skip it unless you need it; AWS flags it as advanced and points to global helpers as the simpler path. After Control Tower vends an account, AFT runs a fixed sequence, and one step in it is your state machine.
sfn-prov) runs inside the vend, ahead of the global and account customization stages.| # | Post-vend step (in order) |
|---|---|
| 1 | Validate the account request. |
| 2 | Store metadata in DynamoDB in the AFT management account. |
| 3 | Create the AWSAFTExecution role in the new account (AFT assumes it to customize; grants account-factory portfolio access). |
| 4 | Apply account tags. |
| 5 | Apply feature options. |
| 6 | Run your account-provisioning-framework customizations (the Step Functions state machine). |
| 7 | Create a CodePipeline for the account and invoke the customizations pipeline. |
| 8 | Send success / failure to SNS. |
Step 6 is a Step Functions state machine you define in aft-account-provisioning-customizations. If you don't define one, the stage is a no-op. To add one, edit the example customizations.asl.json and drop the backing Terraform (e.g. a lambda.tf with the function and its IAM role) in the same repo. Common integrations: Lambda, ECS/Fargate tasks, Step Functions activities, SNS/SQS.
Living with it — updates, re-runs, and teardown
Updating AFT. Sign in to the CT management account, bump aft_framework_repo_git_ref to the new release tag, terraform plan the delta, then apply. Prefer this pinned-tag path over the doc's main-tracking shortcut (terraform get -update) so upgrades stay deliberate and reviewable. Check release notes first — some fixes need a minimum version (customization-workflow fixes landed in 1.8.0; nested-OU re-invoke targeting needs 1.6.5+).
Re-running. To re-apply the provisioning framework, change an account in the request repo or vend a new one. To re-apply customizations to existing accounts (a push alone does nothing), start the aft-invoke-customizations Step Function with an include/exclude event — target all, or by ous, tags, or accounts. AFT processes 5 at a time and loops until done:
{
"include": [
{ "type": "tags", "target_value": [ { "ABC:Environment": "Dev" } ] }
]
}
Feature options. Three baseline behaviors are module flags, all default false: aft_feature_cloudtrail_data_events (org-level CloudTrail data-event logging), aft_feature_enterprise_support (auto-enroll vended accounts into Enterprise Support — cost implications), and aft_feature_delete_default_vpcs_enabled (remove the default VPC in every account).
Cost lever. By default AFT runs inside a VPC with NAT gateways and endpoints. Setting aft_enable_vpc = false removes that private-networking cost for some usage patterns — but re-enabling it later can require running terraform apply twice in succession.
terraform destroy from the same state you deployed with — which is exactly why preserving that state file from day one matters. Empty or decommission vended accounts through Control Tower separately; destroying the framework does not close the accounts it vended.Where AFT bites — the black-box gotchas
AFT works, but it is opaque. Practitioners who have run it for years agree: powerful for its narrow job, frustrating outside it. The watch-outs, collected:
| Pitfall | Why it happens | Fix / guardrail |
|---|---|---|
| Requests vanish silently | A malformed or racing request lands in aft-account-request-dlq.fifo instead of being processed by the aft-account-request-processor Lambda. | If a push produced no pipeline run, check the DLQ. |
| Immutable identity fields | control_tower_parameters can't change post-vend. | A typo in email or OU means recreating, not editing — get it right the first time. |
| Manual OU moves aren't drift | Terraform manages a DynamoDB item, not the account. | Move OUs through the request repo, not by hand; AFT stays silent otherwise. |
| Don't hand-edit AFT's IAM roles | Changing the roles Account Factory creates breaks the pipeline. The default AWSAFTExecution/AWSAFTAdmin roles are powerful — assuming them can mean admin on the org management account. | Leave the roles alone; guard access to the AFT management account tightly (least privilege). |
| No feature-branch testing | Apply happens only from the default branch; defects surface in production and hit all new accounts. | Vend a throwaway account to test customizations. |
| It's a black box | No console section, no dashboards, no built-in metrics. | Observe via CloudWatch Logs Insights (AFT ships a request-tracing token and sample queries), the SNS failure topic, and per-account pipeline views. |
| Invoke-success ≠ pipeline-success | The Step Function reports success on invoking a pipeline, not on it finishing. | Monitor the per-account pipeline runs, not just the state machine. |
| Not for multi-region / fast-changing infra | AFT targets one region without custom regional providers, and batches of 5 make wide rollouts slow. | Keep it to account bootstrap; use a different pipeline for anything that changes often. |
| You own the state | AFT does not manage a backend. | Stand up S3 + DynamoDB from day one; lose the state and updates/teardown get painful. |
The honest verdict. If you're vending many accounts that need a consistent baseline, AFT earns its keep — a GitOps account lifecycle with a real audit trail is worth a lot. Budget one to two weeks and people who know both Terraform and Control Tower. But if you only have a handful of accounts, or you're tempted to run application infrastructure through it, don't: you'll fight the black box for less than it gives back.
References
- AWS — Overview of AWS Control Tower Account Factory for Terraform (AFT). https://docs.aws.amazon.com/controltower/latest/userguide/aft-overview.html
- AWS — Deploy AWS Control Tower Account Factory for Terraform (AFT). https://docs.aws.amazon.com/controltower/latest/userguide/aft-getting-started.html
- AWS — AFT Architecture. https://docs.aws.amazon.com/controltower/latest/userguide/aft-architecture.html
- AWS — AFT account provisioning pipeline. https://docs.aws.amazon.com/controltower/latest/userguide/aft-provisioning-framework.html
- AWS — Provision a new account with AFT. https://docs.aws.amazon.com/controltower/latest/userguide/aft-provision-account.html
- AWS — Account customizations. https://docs.aws.amazon.com/controltower/latest/userguide/aft-account-customization-options.html
- AWS — Post-deployment steps. https://docs.aws.amazon.com/controltower/latest/userguide/aft-post-deployment.html
- AWS — AFT troubleshooting guide. https://docs.aws.amazon.com/controltower/latest/userguide/account-troubleshooting-guide.html
- AWS — Update the AFT version. https://docs.aws.amazon.com/controltower/latest/userguide/update-aft-version.html
- GitHub — aws-ia/terraform-aws-control_tower_account_factory (README, variables.tf, example account-request.tf, release 1.20.1). https://github.com/aws-ia/terraform-aws-control_tower_account_factory
- Terraform Registry — aws-ia/control_tower_account_factory/aws. https://registry.terraform.io/modules/aws-ia/control_tower_account_factory/aws/latest
- HashiCorp — Manage AWS accounts using Control Tower Account Factory for Terraform. https://developer.hashicorp.com/terraform/tutorials/aws/aws-control-tower-aft
- Ronen Dancziger — Account Factory for Terraform: Lessons from the Trenches (Practical AWS, Medium). https://medium.com/practical-aws/account-factory-for-terraform-lessons-from-the-trenches-7e86a5c4d3ee
- OpenCredo — AWS Account Factory for Terraform: get inside the black box. https://opencredo.com/blogs/aws-account-factory-for-terraform-get-inside-the-black-box-by-thinking-outside-the-box
- GitHub Issues — #61 (account creation failure), #404 (aft-customizations-execute-pipeline); AWS re:Post (customization resources not created). https://github.com/aws-ia/terraform-aws-control_tower_account_factory/issues