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.

Implementation Guide · Build · platform & cloud engineers ·

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 apply it 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-request vends accounts; aft-global-customizations baselines all of them; aft-account-customizations tailors specific ones; aft-account-provisioning-customizations injects steps mid-provision. After setup, account lifecycle is a git push.
  • Pin the version and own your state. Set aft_framework_repo_git_ref to a release tag (e.g. 1.20.1) instead of tracking main, 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.
Keep it in its lane. AFT is a bootstrap-and-baseline machine, not application infrastructure. Vend and standardize accounts with it; run your apps through a separate pipeline.

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 needWhy
A working Control Tower landing zoneAFT governs accounts through Control Tower; it doesn't replace it. Hard prerequisite — note your CT home Region.
Control Tower management account + an admin roleYou 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 IDsRequired module inputs; created by Control Tower during landing-zone setup.
Terraform ≥ 1.6.1 and a state backendRuns the module; AFT does not manage your backend. Community Edition, HCP Terraform, or Enterprise all work.
A VCS provider via CodeConnectionsHosts the four repos. CodeCommit by default; GitHub / GitLab / Bitbucket / Azure DevOps via CodeConnections.
An OU for AFTAWS strongly recommends a separate OU to contain the framework's blast radius.
The one that trips first-timers. If you're deploying AFT for the first time and don't already have a CodeCommit repo, AWS says you must choose an external VCS provider. Decide GitHub-vs-CodeCommit up front — it's a module input you don't want to change later.

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).

Validation. In Control Tower the AFT management account shows Enrolled under your new OU. Record its 12-digit ID plus the Log Archive, Audit, and CT management account IDs — you need all four for the module.

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.

Version pinning is not optional in production. The AWS "update AFT" doc pulls 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
Validation & watch-out. CodeConnections status reads Available and each repo's default branch carries the template folders. AWS lists "repositories not populated correctly" as a top failure — this phase exists to prevent it. And AFT applies only from the default branch: customization changes go straight to the branch that governs all new accounts, so treat these default branches as production.

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).

#WhereWhat happens
1repo-requestThe git push of the account-request .tf triggers the ct-aft-account-request pipeline.
2request-pipelineCodePipeline + CodeBuild process the request and hand it to the processor Lambda.
3lambda / ddbThe request is written to the aft-request-metadata table (FIFO); malformed or racing requests land in aft-account-request-dlq.fifo.
4ct-mgmtControl Tower vends the account through Service Catalog — this stage runs in the CT management account.
5sfn-provProvisioning framework: validate → store metadata → create the AWSAFTExecution role → apply tags → apply feature options.
6per-acct-pipelineAFT creates one customization pipeline for the account and invokes it.
7aftexec-roleThe pipeline assumes AWSAFTExecution and applies global then account customizations into the vended account.
8snsSuccess and failure both post to aft-notifications / aft-failure-notifications.
The two gotchas that fail a first vend. (1) The account email and name must be globally unique — a duplicate surfaces as a Service Catalog failure or a 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.

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"
}
Validation & rollback. After the vend, the account's customization pipeline runs the global stage green and the 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.

Silent failure at scale. A practitioner running 100+ accounts found the orchestrator reports success on invoking each pipeline, not on the pipeline finishing — the Step Function can show green while individual account pipelines fail. Watch the per-account CodePipeline runs and the failure SNS topic, not just the state machine. AFT processes at most 5 accounts at a time and loops until done, so a large fleet takes a while (a couple of hours across ~100 accounts).

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.

#Post-vend step (in order)
1Validate the account request.
2Store metadata in DynamoDB in the AFT management account.
3Create the AWSAFTExecution role in the new account (AFT assumes it to customize; grants account-factory portfolio access).
4Apply account tags.
5Apply feature options.
6Run your account-provisioning-framework customizations (the Step Functions state machine).
7Create a CodePipeline for the account and invoke the customizations pipeline.
8Send 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.

Validation & watch-out. Vend a test account and confirm your state machine execution appears in the provisioning-framework run, before the global stage. It runs on every vend and can't be moved to the global pre-API stage — a slow or flaky state machine slows down every account you create.

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.

Teardown. (Author guidance — AWS documents state-file responsibility but not a canonical teardown runbook.) Because the framework is one Terraform root, tearing it down is a 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:

PitfallWhy it happensFix / guardrail
Requests vanish silentlyA 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 fieldscontrol_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 driftTerraform 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 rolesChanging 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 testingApply 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 boxNo 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-successThe 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 infraAFT 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 stateAFT 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

  1. AWS — Overview of AWS Control Tower Account Factory for Terraform (AFT). https://docs.aws.amazon.com/controltower/latest/userguide/aft-overview.html
  2. AWS — Deploy AWS Control Tower Account Factory for Terraform (AFT). https://docs.aws.amazon.com/controltower/latest/userguide/aft-getting-started.html
  3. AWS — AFT Architecture. https://docs.aws.amazon.com/controltower/latest/userguide/aft-architecture.html
  4. AWS — AFT account provisioning pipeline. https://docs.aws.amazon.com/controltower/latest/userguide/aft-provisioning-framework.html
  5. AWS — Provision a new account with AFT. https://docs.aws.amazon.com/controltower/latest/userguide/aft-provision-account.html
  6. AWS — Account customizations. https://docs.aws.amazon.com/controltower/latest/userguide/aft-account-customization-options.html
  7. AWS — Post-deployment steps. https://docs.aws.amazon.com/controltower/latest/userguide/aft-post-deployment.html
  8. AWS — AFT troubleshooting guide. https://docs.aws.amazon.com/controltower/latest/userguide/account-troubleshooting-guide.html
  9. AWS — Update the AFT version. https://docs.aws.amazon.com/controltower/latest/userguide/update-aft-version.html
  10. 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
  11. Terraform Registry — aws-ia/control_tower_account_factory/aws. https://registry.terraform.io/modules/aws-ia/control_tower_account_factory/aws/latest
  12. HashiCorp — Manage AWS accounts using Control Tower Account Factory for Terraform. https://developer.hashicorp.com/terraform/tutorials/aws/aws-control-tower-aft
  13. 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
  14. 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
  15. 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