AWS IaC

Account Factory for Terraform (AFT): Pipeline-Driven Account Vending and Customizations at Scale

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:

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" (or github-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. Leave vcs_provider unset (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 || true or 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.

  1. 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
  1. DynamoDB request tables. The aft-request table holds the desired state; aft-request-metadata records 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
  1. Service Catalog provisioned product. The actual Control Tower call. A TAINTED or ERROR provisioned 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.

Checklist

awscontrol-towerterraformaftlanding-zoneaccount-vending

Comments

Keep Reading