AWS
23 min readJuly 31, 2026Updated August 19, 2026

AWS Account Factory for Terraform (AFT): What It Is, How It Works, and When It's Overkill

AJ
Ajeet Yadav
Platform & Cloud Engineer
AWS Account Factory for Terraform (AFT): What It Is, How It Works, and When It's Overkill

Quick answer

AFT turns AWS account creation into a pull request. It is also a dedicated account, four git repositories, a Step Functions pipeline, and a standing NAT gateway bill before you vend anything. Here's how it actually works, how it compares to stock Control Tower and Landing Zone Accelerator, and the account count below which it is the wrong tool.

23 min read · AWS

AWS Account Factory for Terraform (AFT) is a GitOps pipeline that vends AWS Control Tower accounts from Terraform code. You commit a .tf file describing an account, AFT creates it through Control Tower, then applies your organisation-wide and per-account Terraform on top of it. It does not replace CloudFormation, and it does not replace Control Tower — it wraps Control Tower's account lifecycle in a pipeline you drive from a git repository.

That last sentence is the one people get wrong, so let me be blunt about it up front: AFT is not an alternative to CloudFormation. Control Tower's Account Factory is built on Service Catalog, which is built on CloudFormation, and AFT calls it. When you use AFT, CloudFormation is still creating your accounts. You have just stopped clicking the button yourself.

What follows is how the thing actually works, what it costs, how it stacks up against the alternatives, and the point below which I'd tell you not to bother.

The problem AFT actually solves

Control Tower gives you Account Factory out of the box. It works. You open the console, fill in an account name, an email address, an OU, and an SSO user, and twenty-five minutes later you have a governed account with guardrails applied.

Then you do it again. And again. And by the fortieth account you have discovered three things:

The request is not recorded anywhere useful. Someone created data-eng-prod in March. Who asked for it? What cost centre? Was the OU choice deliberate? The answer lives in a Slack thread that has since aged out of the free tier.

Every account is a blank slate. Control Tower applies guardrails and enrols the account in CloudTrail and Config. It does not create your standard IAM roles, your break-glass user, your VPC layout, your Security Hub delegated admin wiring, your budget alarms, or your tag policies. Somebody does that by hand, differently, every time.

Drift accumulates silently. Account 3 got the old IAM boundary policy. Account 17 got the new one. Nobody knows which accounts have which, and there is no single terraform apply that will tell you.

The obvious engineer's reaction is "fine, I'll just write Terraform." That runs into a wall: Control Tower owns the account lifecycle. If you create accounts with aws_organizations_account directly, they are not enrolled in Control Tower, they do not get guardrails, and enrolling them afterwards is a manual, error-prone chore. You end up fighting the service. AFT exists precisely because Control Tower must stay in charge of creating the account, while you want everything before and after that moment to be code.

How AFT works

The accounts involved

AFT spans four accounts, and this is the first thing that surprises people:

AccountRole
Control Tower managementWhere Control Tower lives. AFT assumes a role here to call Account Factory. You do not run AFT's infrastructure here.
AFT managementA dedicated account that holds all of AFT's own infrastructure — pipelines, state, Lambdas, the lot. It must exist before you deploy AFT: provision it through Control Tower, or create it by other means and enrol it. AWS strongly recommends putting it in its own OU.
Log ArchiveControl Tower core account. AFT reads and writes here.
AuditControl Tower core account. Used for cross-account audit access.

The dedicated AFT management account is not optional and it is not shared. If your reaction is "that's a whole account just to run a pipeline" — yes, and that's a fair objection I'll come back to in the anti-pitch.

The four repositories

AFT is driven by four git repositories, each with a fixed job:

  • aft-account-request — one Terraform module block per account you want to exist. This is the repository your platform team takes pull requests against.
  • aft-global-customizations — Terraform that runs in every vended account. Standard IAM roles, budget alarms, tag enforcement, security agents.
  • aft-account-customizations — Terraform that runs in some accounts, selected by name. One subdirectory per customization set.
  • aft-account-provisioning-customizations — an optional Step Functions state machine that runs between account creation and customizations. Use it for the things that aren't Terraform: opening a ticket, calling an internal API, waiting on an approval.

The pipeline

Push to aft-account-request and this happens:

  1. A CodePipeline in the AFT management account picks up the commit and runs terraform apply against the account request module. That writes a row into a DynamoDB request table.
  2. A DynamoDB stream fires a Lambda, which drops the request onto an SQS FIFO queue.
  3. A Step Functions state machine picks it up, assumes a role into the Control Tower management account, and calls Service Catalog to provision the account. This is the step where CloudFormation is doing the work.
  4. Control Tower creates and enrols the account. This takes roughly 20–30 minutes and you cannot speed it up.
  5. Once the account exists, AFT builds a dedicated CodePipeline for that account in the AFT management account.
  6. Your provisioning customizations state machine runs, if you defined one.
  7. That per-account pipeline runs aft-global-customizations, then aft-account-customizations for whichever customization set the request named.

Drawn out, with the account boundaries made explicit — because which account a thing runs in is the part that's hardest to hold in your head:

Rendering diagram…

The Log Archive and Audit accounts are left out of that diagram — AFT holds credentials for them, but they sit outside the vending path.

The important structural detail: every vended account gets its own pipeline that persists. Re-running customizations later means re-running that account's pipeline, not re-vending the account.

And because the ordering of the four repositories is the other thing people consistently get wrong — they are not four independent things, they are four stages of one sequence:

Rendering diagram…

Deploying AFT

The deployment module is the one piece of Terraform you run by hand. There is a chicken-and-egg problem here that nobody warns you about: AFT builds all the CI/CD you will use for accounts, but AFT itself is applied from a laptop or a bootstrap pipeline, and you own its state file. AWS is unambiguous about this — "The AFT module doesn't manage a backend Terraform state", and "You're responsible for protecting the Terraform state file." Some inputs, such as an SSH key or a Terraform token, land in that state in plain text. Put it somewhere real before you start — an S3 bucket in a shared services account with locking, versioning, and encryption — because you will need it every time you upgrade.

Run the module with credentials for the Control Tower management account carrying AdministratorAccess. Deployment can take up to 30 minutes, and AWS recommends STS credentials with a sufficient timeout — the minimum STS session of 60 minutes is the safe floor, because a credential expiry mid-apply fails the deployment.

hcl
1terraform {
2  required_version = ">= 1.6.1, < 2.0.0"
3
4  backend "s3" {
5    bucket = "acme-tfstate-shared"
6    key    = "aft/deployment.tfstate"
7    region = "eu-west-1"
8  }
9}
10
11module "aft" {
12  source = "github.com/aws-ia/terraform-aws-control_tower_account_factory?ref=1.21.1"
13
14  # The four accounts
15  ct_management_account_id  = "111111111111"
16  log_archive_account_id    = "222222222222"
17  audit_account_id          = "333333333333"
18  aft_management_account_id = "444444444444"
19
20  # Regions
21  ct_home_region              = "eu-west-1"
22  tf_backend_secondary_region = "eu-west-2"
23
24  # Version control
25  vcs_provider                                  = "github"
26  account_request_repo_name                     = "acme/aft-account-request"
27  global_customizations_repo_name               = "acme/aft-global-customizations"
28  account_customizations_repo_name              = "acme/aft-account-customizations"
29  account_provisioning_customizations_repo_name = "acme/aft-account-provisioning-customizations"
30  account_request_repo_branch                   = "main"
31
32  # Terraform runtime used inside the pipelines
33  terraform_distribution = "oss"
34  terraform_version      = "1.9.8"
35
36  # Feature flags
37  aft_feature_delete_default_vpcs_enabled = true
38  aft_feature_enterprise_support          = false
39  aft_feature_cloudtrail_data_events      = false
40
41  # Cost control — see the anti-pitch section
42  aft_enable_vpc        = false
43  aft_metrics_reporting = false
44}

Pin the ref. AFT ships breaking changes, and source without a ref means your next terraform init -upgrade is a surprise. At the time of writing the current release is 1.21.1.

Four notes on this block:

Mind the exact variable names. It is aft_feature_delete_default_vpcs_enabled, not aft_feature_delete_default_vpcs. The module validates inputs, so a typo fails fast rather than silently — but the naming is inconsistent enough between flags that it's worth reading variables.tf for the release you're pinning.

tf_backend_secondary_region is optional but you want it. It defaults to "". AFT uses it to replicate its own Terraform state backend into a second region, and nothing stops you leaving it empty or setting it to the same region as ct_home_region — the module validates the two independently. Set it to a genuine second region anyway; the whole point is that your account factory's state survives a regional event.

Pick your VCS deliberately. AFT defaults to codecommit and will provision CodeCommit repositories for you. AWS is explicit that this default is a dead end for new adopters: "If you're deploying AFT for the first time and you don't have an existing CodeCommit repository, you must choose an external VCS provider, such as GitHub or BitBucket." Valid values are codecommit, bitbucket, github, githubenterprise, gitlab, gitlabselfmanaged, and azuredevops. Choose on day one — migrating VCS afterwards is possible but genuinely unpleasant.

terraform_version is the version AFT runs inside the CodeBuild pipelines, which is separate from the version you use to apply the deployment module. Keep both current and keep them in mind when you write customizations.

Vending an account

An account request is a module block in aft-account-request. One file per account, or grouped by OU — AFT doesn't care.

hcl
1module "data_platform_prod" {
2  source = "./modules/aft-account-request"
3
4  control_tower_parameters = {
5    AccountEmail              = "[email protected]"
6    AccountName               = "data-platform-prod"
7    ManagedOrganizationalUnit = "Workloads (ou-ab12-cdef3456)"
8    SSOUserEmail              = "[email protected]"
9    SSOUserFirstName          = "Cloud"
10    SSOUserLastName           = "Platform"
11  }
12
13  account_tags = {
14    "acme:cost-centre" = "4417"
15    "acme:environment" = "production"
16    "acme:owner"       = "data-platform"
17    "acme:data-class"  = "confidential"
18  }
19
20  change_management_parameters = {
21    change_requested_by = "Data Platform Team"
22    change_reason       = "CHG-2291 — production workload separation"
23  }
24
25  custom_fields = {
26    network_tier   = "private-egress"
27    backup_policy  = "daily-35d"
28  }
29
30  account_customizations_name = "workload-production"
31}

This is the payoff. The pull request is the change record — who asked, why, what tags, which OU, reviewed by whom, timestamped in git. That solves problem one from the top of this post, and it solves it for free.

Some practical realities:

  • AccountEmail must be globally unique and permanent. Use plus-addressing against a distribution list you control. Changing it later is not a Terraform operation.
  • ManagedOrganizationalUnit takes a plain name for top-level OUs, but nested OUs need the Name (ou-id) form shown above. Getting this wrong is the single most common first-vend failure.
  • Expect 25–40 minutes per account, and Control Tower limits concurrent operations. Twenty accounts is an afternoon, not a coffee break. Submit the batch and go do something else.
  • custom_fields is your escape hatch. Anything you put here is available to your customization code, which is how you drive conditional logic without hardcoding account IDs.

Terraform Day-2 Operations Checklist

State hygiene, drift, imports, policy checks, and upgrade routines — everything after `terraform apply` works. Plain Markdown, commit it to your repo.

Free. Instant download. You'll also get the occasional deep-dive from the newsletter — unsubscribe anytime.

Customizations

Global customizations

aft-global-customizations has a fixed layout:

aft-global-customizations/
├── terraform/
│   ├── main.tf
│   └── ...
└── api_helpers/
    ├── pre-api-helpers.sh      # bash, runs before terraform
    ├── post-api-helpers.sh     # bash, runs after terraform
    └── python/
        └── requirements.txt    # python helpers live here

Bash goes in api_helpers/, Python in api_helpers/python/, Terraform in terraform/. The buildspec runs pre-api-helpers.sh, then terraform apply, then post-api-helpers.sh.

Now the part that trips people up: AFT does not pass any Terraform variables into your customizations. The pipeline runs a bare terraform apply with no -var flags. What AFT generates for you is a providers.tf and a backend.tf from Jinja templates — and the generated provider already assumes the AWSAFTExecution role in the vended account. So you find out where you are the ordinary Terraform way:

hcl
1data "aws_caller_identity" "current" {}
2data "aws_region" "current" {}
3
4# No variables are injected, so constants live in the repo
5locals {
6  security_account_id = "333333333333"
7}
8
9# custom_fields from the account request are written into the vended
10# account as SSM parameters under /aft/account-request/custom-fields/
11data "aws_ssm_parameter" "backup_policy" {
12  name = "/aft/account-request/custom-fields/backup_policy"
13}
14
15resource "aws_budgets_budget" "account_monthly" {
16  name         = "account-monthly-guardrail"
17  budget_type  = "COST"
18  limit_amount = "5000"
19  limit_unit   = "USD"
20  time_unit    = "MONTHLY"
21}
22
23resource "aws_iam_role" "platform_break_glass" {
24  name = "acme-break-glass"
25
26  assume_role_policy = jsonencode({
27    Version = "2012-10-17"
28    Statement = [{
29      Effect    = "Allow"
30      Action    = "sts:AssumeRole"
31      Principal = { AWS = "arn:aws:iam::${local.security_account_id}:root" }
32      Condition = { Bool = { "aws:MultiFactorAuthPresent" = "true" } }
33    }]
34  })
35}

That custom_fields → SSM mapping is the mechanism worth knowing. During the provisioning framework stage, AFT writes each key from your account request's custom_fields map into the vended account as an SSM parameter under /aft/account-request/custom-fields/. That is how per-account variation reaches your customization code without hardcoding account IDs — read them with aws_ssm_parameter in Terraform, or aws ssm get-parameter in the shell hooks.

The CodeBuild environment also exports VENDED_ACCOUNT_ID, which is handy in api_helpers scripts. It is not a TF_VAR_, so Terraform does not see it — use aws_caller_identity there instead.

Account customizations

aft-account-customizations uses one subdirectory per set, named to match account_customizations_name in the request. AFT seeds the repo with an ACCOUNT_TEMPLATE folder — copy it rather than building the structure by hand, since it carries the aft-providers.jinja and backend.jinja templates the pipeline expects:

aft-account-customizations/
├── workload-production/
│   ├── terraform/
│   └── api_helpers/
├── workload-nonprod/
│   ├── terraform/
│   └── api_helpers/
└── sandbox/
    ├── terraform/
    └── api_helpers/

Keep the number of sets small. I've seen teams end up with one customization set per account, at which point they have rebuilt the problem AFT was supposed to solve — a pile of bespoke per-account config with no shared baseline. Two or three sets keyed to account class, with custom_fields handling the per-account variation, is the shape that stays maintainable.

The re-run problem, and what changed in July 2026

Here is the gotcha that bites everyone: updating a customization repository does not automatically re-apply it to existing accounts. Global customizations run at vend time. Change the budget alarm in aft-global-customizations, and your forty existing accounts keep the old one until you trigger their pipelines.

AFT ships a Step Functions state machine for this, called aft-invoke-customizations, in the AFT management account. You start it with an event that includes or excludes accounts by ID, OU, or tag:

json
1{
2  "include": [
3    { "type": "ous", "target_value": ["Workloads", "Sandbox (ou-ab12-cdef3456)"] }
4  ],
5  "exclude": [
6    { "type": "accounts", "target_value": ["123456789012"] }
7  ],
8  "bypass_steps": ["provisioning_bootstrap"]
9}

{"type": "all"} targets every AFT-provisioned account. bypass_steps: ["provisioning_bootstrap"] skips the provisioning and bootstrap phases so only the customization pipelines run — that's the one you want for a routine baseline change. AFT re-invokes a maximum of 5 customizations at a time and loops until the matched set is done, so a fleet-wide re-run is a background job, not an instant operation.

Wire this into a scheduled task or a manual pipeline stage. Leaving it as "someone remembers to start the state machine" is exactly how drift comes back.

As of July 2026, AFT can also re-apply customizations automatically when an account moves between OUs:

hcl
module "aft" {
  # ...
  aft_customization_triggers = ["account_move"]
}

That closes a real hole. Previously, promoting an account from Sandbox to Workloads changed its guardrails but left its customizations untouched, so the account looked governed and wasn't. Worth turning on. The same release added custom Terraform Cloud/Enterprise workspace naming and tighter access controls on the AFT logging bucket.

AFT vs the alternatives

Stock Account FactoryAFTLanding Zone AcceleratorDIY Terraform + Organizations
InterfaceConsole / Service CatalogTerraform in gitYAML config files in gitTerraform in git
Underlying IaCCloudFormationCloudFormation (via CT) + TerraformCloudFormation / CDKTerraform only
Requires Control TowerYesYesNo (deploys its own LZ)No
Account request audit trailNoneGit historyGit historyGit history
Post-vend customizationBlueprints (AFC), CFN-basedTerraform, global + per-accountPrescribed by config schemaWhatever you build
Drift detectionNoneRe-run pipelinesCodePipeline re-deployterraform plan
Operational weightNear zeroHigh — dedicated account, 4 repos, pipelinesVery high — large prescribed footprintMedium — you own everything
Best for<15 accounts, low change rateTerraform shops, 20+ accounts, Control Tower already in placeRegulated / gov workloads needing prescribed controlsTeams who need control CT won't give them

Stock Account Factory deserves more credit than it gets. Combined with Account Factory Customization (AFC) blueprints, it covers a lot of ground with zero infrastructure of your own. If you're a CloudFormation shop, AFC is the natural answer and AFT is a strange detour.

Landing Zone Accelerator is the CloudFormation-native sibling. It is config-driven rather than code-driven — you describe your landing zone in YAML and LZA deploys a large, opinionated, heavily prescribed control set. It goes much further than AFT (network architecture, security services, centralised logging) and is correspondingly harder to deviate from. If you're in a regulated environment matching one of its published architectures, it saves enormous time. If you want to make your own decisions, it fights you.

DIY is not the joke option. If Control Tower's constraints don't fit — you need account structures it won't express, or you're not in a supported region set — building account vending on aws_organizations_account plus your own pipeline is entirely reasonable. You lose guardrails and enrolment, and you own the whole lifecycle, but you own it deliberately.

Where CloudFormation actually fits

Since this is the comparison people search for, let me lay it out plainly.

CloudFormation is not AFT's competitor. It's AFT's substrate. Control Tower provisions accounts through a Service Catalog product, and Service Catalog products are CloudFormation stacks. AFT's Step Functions state machine calls Service Catalog. So the account creation itself is CloudFormation, whichever route you take.

What AFT changes is the language of everything around that call:

  • The request becomes Terraform instead of a console form or a Service Catalog API call.
  • The post-vend configuration becomes Terraform instead of CloudFormation blueprints.

If you'd rather that surrounding layer be CloudFormation — because your team knows it, because you want StackSets, because you're already invested in CDK — then AFC blueprints and LZA are the coherent choices, and AFT buys you nothing but a Terraform dependency. The real question isn't "AFT or CloudFormation", it's "which language do you want your landing zone written in, given that the account creation itself is CloudFormation either way?"

For the broader trade-offs between the two languages, I've written that up separately in Terraform vs CloudFormation.

When AFT is the wrong tool

I've deployed AFT and I'd deploy it again — for the right organisation. Here is when it isn't. If you want the short version, it's four questions:

Rendering diagram…

In detail:

You don't have Control Tower. Full stop. AFT is a Control Tower add-on. No Control Tower, no AFT, and adopting Control Tower solely to get AFT is an enormous tail wagging a small dog.

You have fewer than fifteen or twenty accounts, and you're not adding many. This is the big one. AFT's fixed cost is a dedicated AWS account, four repositories, a Step Functions/DynamoDB/Lambda/CodeBuild pipeline you now operate, and an upgrade obligation. If you vend six accounts a year, the console button plus a well-written runbook is genuinely the better engineering decision. Automation has to amortise.

The running cost is not zero. By default AFT deploys its own VPC in the AFT management account, with NAT gateways and a set of interface VPC endpoints. That private networking, not the compute, is the bulk of the bill, and it accrues before you vend a single account. AWS documents the lever explicitly: aft_enable_vpc = false deploys AFT without a VPC and without NAT gateways or endpoints, and the docs note it "may help reduce the operating cost of AFT for some usage patterns." A narrower option is aft_vpc_endpoints = false, which drops the interface endpoints but keeps the VPC and its NAT gateways. You can also point AFT at an existing VPC with aft_customer_vpc_id and aft_customer_private_subnets.

Two caveats from the docs: any explicit VPC configuration overrides aft_enable_vpc = false, and flipping the flag back from false to true may require running terraform apply twice. Decide before you deploy — the custom-VPC option in particular is not recommended for an existing AFT installation. If AWS spend is a live concern, my AWS cost optimization guide and the NAT gateway cost post cover the wider pattern.

Debugging means reading Step Functions executions. When a vend fails, the error is not in your Terraform output. It's in a state machine execution history in the AFT management account, possibly in a CodeBuild log, possibly in a Service Catalog provisioned product error in a different account. This is a real operational skill your team has to acquire, and the first incident is slow.

Upgrades are a chore you cannot skip. New AFT versions bring new required variables, changed resource addresses, and occasional state surgery. You re-run the deployment module by hand, read the release notes properly, and sometimes fix things. Budget for it a few times a year.

Your organisation is a CloudFormation shop. If nobody writes Terraform, AFT introduces a language, a state model, and a provider ecosystem to a team that has to learn all three before they can vend an account. AFC blueprints do the same job in the language you already speak.

You want OpenTofu. AFT's terraform_distribution supports oss, tfc, and tfe — HashiCorp Terraform and Terraform Cloud/Enterprise. OpenTofu is not an officially supported distribution. If you've moved off Terraform over the BUSL licence change, this is a hard constraint worth checking before you commit. I've compared the two in OpenTofu vs Terraform.

The honest summary: AFT is worth its weight when account vending is a recurring, multi-team, auditable process — and it's overhead when it's an occasional platform-team task. Count how many accounts you created last year. If the answer is single digits, write the runbook instead.

Frequently Asked Questions

Do I need AWS Control Tower to use AFT?

Yes. AFT is not a standalone account provisioning tool — it orchestrates Control Tower's Account Factory. You need a working Control Tower landing zone with its Log Archive and Audit accounts, plus a separate account for AFT itself, before you can deploy the module.

How long does it take to vend an account with AFT?

Typically 25–40 minutes end to end. Roughly 20–30 minutes of that is Control Tower creating and enrolling the account, which you cannot speed up, and the remainder is your global and account customizations running through the per-account CodePipeline. Control Tower also limits concurrent operations, so large batches are processed with limited parallelism rather than all at once.

Can AFT manage accounts that already exist?

Yes, but with caveats. Existing Control Tower-enrolled accounts can be brought under AFT management by writing a matching account request — the request fields must line up with the account's current state. Accounts that aren't enrolled in Control Tower must be enrolled first. Importing existing accounts is closer to a Terraform import exercise than a fresh vend, and it's worth doing a handful at a time.

Does AFT work with Terraform Cloud or OpenTofu?

Terraform Cloud and Terraform Enterprise are both supported via the terraform_distribution variable, with OIDC integration available. OpenTofu is not an officially supported distribution — if your organisation has standardised on OpenTofu, verify this against the current module release before planning an AFT adoption.

How much does AFT cost to run?

There is a standing baseline cost regardless of vending volume, driven by the private networking AFT deploys in the AFT management account — NAT gateways and interface VPC endpoints — rather than by compute. AWS's documented lever is aft_enable_vpc = false, which deploys AFT without a VPC, NAT gateways, or endpoints; aft_vpc_endpoints = false is the narrower option that keeps the VPC. Costs scale only mildly with vending volume, since each account gets its own CodePipeline, so the baseline dominates for most organisations. Model it against your own region's pricing before committing.

See also


Building out a multi-account AWS landing zone, or trying to work out whether AFT is worth the weight for your account count? Get in touch — I've been through this decision from both sides and I'm happy to tell you when the answer is "don't".

Official References

Was this article helpful?

Be the first to rate this article

Related Topics

AWS
Terraform
Control Tower
AFT
Landing Zone
Multi-Account
IaC
Platform Engineering

Found this useful? Share it.

Practice this

Related tools

Read Next