A landing zone is the governed, pre-wired AWS foundation your workloads land in — accounts, OUs, identity, logging, and guardrails already in place so teams ship without re-litigating security per project. AWS Control Tower orchestrates this on top of AWS Organizations, and Account Factory for Terraform (AFT) turns account creation into a GitOps pipeline. This guide builds the whole thing the way it’s done in regulated enterprises.
Why a landing zone: account-as-blast-radius
The core principle is simple: the AWS account is your strongest isolation boundary. IAM, SCPs, networking, and billing all stop at the account edge. So instead of cramming prod and dev into one account separated by tags and hope, you give each workload-and-environment its own account. A compromised dev account cannot reach prod. A runaway Lambda can’t exhaust prod’s concurrency. A DeleteBucket blast radius is one account, not the company.
Accounts are cheap; the hard part is governing hundreds of them consistently. That is what Control Tower and an organizational unit (OU) strategy solve. OUs are where you attach Service Control Policies (SCPs) and Control Tower controls once and inherit everywhere beneath.
Mental model: accounts are the unit of isolation and billing. OUs are the unit of policy. You design the OU tree around how you want to govern, not around your org chart.
Step 1 — Enable Control Tower and design the OU hierarchy
Control Tower is set up from the management account (the Organizations root payer). Before you click anything, decide your home region carefully — it anchors the landing zone and is painful to change later. Enable it from the console (Set up landing zone) or, if you prefer IaC from day one, the aws controltower API once the prerequisites exist. The console wizard is genuinely the right call for the initial enablement because it provisions the audit and log archive accounts atomically.
During setup you choose two foundational OUs (Control Tower calls the security one the Security OU and the sandbox one the Sandbox OU) and the regions Control Tower governs. Once the landing zone is live, extend the tree to something that scales:
Root
├── Security (Control Tower foundational OU)
│ ├── Log Archive (account)
│ └── Audit (account)
├── Infrastructure (shared platform: networking, CI/CD, DNS)
│ ├── Network (account: Transit Gateway, central egress)
│ └── Shared-Services (account: AD, artifact registries)
├── Workloads
│ ├── Prod
│ └── NonProd
├── Sandbox (Control Tower foundational OU; loose guardrails)
└── Suspended (quarantine: deny-all SCP, pending closure)
This mirrors the AWS multi-account reference. Workloads/Prod and Workloads/NonProd are split so you can attach stricter SCPs (deny leaving approved regions, deny disabling CloudTrail) to prod without slowing down experimentation. Suspended exists for the day you decommission an account — you move it there, attach a deny-all SCP, and it sits inert until closure.
Register additional OUs with Control Tower so it enrolls and governs accounts placed in them. With the CLI:
# OUs themselves are Organizations objects; create under the root or a parent OU
aws organizations create-organizational-unit \
--parent-id "$ROOT_ID" \
--name "Workloads"
aws organizations create-organizational-unit \
--parent-id "$WORKLOADS_OU_ID" \
--name "Prod"
# Then register the OU with Control Tower so its accounts are governed/enrolled
aws controltower enable-baseline \
--baseline-identifier "$AWS_CONTROL_TOWER_BASELINE_ARN" \
--target-identifier "$WORKLOADS_PROD_OU_ARN" \
--baseline-version "4.0"
Why this matters: an OU registered with Control Tower applies its baseline (the mandatory controls and a config recorder) to every current and future account in that OU. New teams inherit guardrails on day one — that is the entire point.
Step 2 — Inside the landing zone: the three foundational accounts
Control Tower creates a deliberate separation of duties across three accounts. Treat these as platform infrastructure, not playgrounds.
| Account | Lives in | Owns |
|---|---|---|
| Management | Root | Organizations, Control Tower, consolidated billing, SCPs. No workloads, ever. |
| Log Archive | Security OU | The immutable, central S3 destination for org CloudTrail and AWS Config logs. |
| Audit | Security OU | Cross-account security tooling: GuardDuty/Security Hub delegated admin, read/audit roles. |
The split exists so that the people who can change the org (management) are not the same as the people who can read every log (audit), and neither can tamper with the log store (archive). Lock the management account down hard: no IAM users, root protected with hardware MFA, access only through IAM Identity Center (formerly AWS SSO) with a tightly scoped permission set, and SCPs that prevent anyone from leaving the org or disabling Control Tower.
A useful pattern is to delegate administration of security services out of the management account to the audit account, keeping the payer account clean:
# Run from the management account: make Audit the org-wide GuardDuty admin
aws guardduty enable-organization-admin-account \
--admin-account-id "$AUDIT_ACCOUNT_ID"
# Same idea for Security Hub
aws securityhub enable-organization-admin-account \
--admin-account-id "$AUDIT_ACCOUNT_ID"
Step 3 — Baseline controls: mandatory, strongly recommended, elective
Control Tower governance is delivered through controls (historically “guardrails”). They come in three behaviors and three categories.
By behavior:
- Preventive — implemented as SCPs; they block non-compliant API calls outright (e.g. “disallow changes to CloudTrail”).
- Detective — implemented as AWS Config rules; they flag drift but don’t stop it (e.g. “detect public read access on S3 buckets”).
- Proactive — implemented as CloudFormation hooks; they block non-compliant resources before provisioning.
By category:
| Category | Behavior | You can disable it? |
|---|---|---|
| Mandatory | Always on; the bedrock of the landing zone | No |
| Strongly recommended | AWS best practice (Well-Architected aligned) | Yes |
| Elective | Common but situational locks | Yes |
The mandatory set is what makes the landing zone trustworthy — it does things like disallow deleting the central log archive, disallow changes to the CloudTrail/Config roles, and disallow public access to the log buckets. Do not fight the mandatory controls. Enable strongly-recommended ones broadly, and apply elective ones surgically to the OUs that need them. Enable a control on an OU via the API:
aws controltower enable-control \
--control-identifier "$STRONGLY_RECOMMENDED_CONTROL_ARN" \
--target-identifier "$WORKLOADS_PROD_OU_ARN"
Beyond Control Tower’s catalog, layer your own custom SCPs at the OU level for org-specific non-negotiables — a region allowlist is the classic one:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyOutsideApprovedRegions",
"Effect": "Deny",
"NotAction": [
"iam:*", "organizations:*", "route53:*",
"cloudfront:*", "support:*", "sts:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["us-east-1", "eu-west-1"]
}
}
}
]
}
Callout: global services (IAM, Route 53, CloudFront, Organizations) authenticate through
us-east-1. If you region-lock with an SCP, you must exempt those actions or you will lock yourself out of IAM. TheNotActionlist above is the safe baseline.
Step 4 — Automating account vending with AFT
Clicking “Enroll account” in the Account Factory console does not scale. Account Factory for Terraform (AFT) wraps Account Factory in a GitOps pipeline: you describe an account in a Terraform request, merge it, and AFT vends and customizes the account end to end.
AFT runs in its own dedicated account and is itself deployed with the published Terraform module. The bootstrap is a one-time apply from a management-context backend:
module "aft" {
source = "aws-ia/control_tower_account_factory/aws"
version = "1.14.0"
# Core account wiring
ct_management_account_id = "111111111111"
log_archive_account_id = "222222222222"
audit_account_id = "333333333333"
aft_management_account_id = "444444444444"
ct_home_region = "us-east-1"
tf_backend_secondary_region = "us-west-2"
# Point AFT at your four pipeline repos (CodeCommit by default,
# or GitHub/GitLab/Bitbucket via *_vcs settings)
account_request_repo_name = "aft-account-request"
global_customizations_repo_name = "aft-global-customizations"
account_customizations_repo_name = "aft-account-customizations"
account_provisioning_customizations_repo_name = "aft-account-provisioning-customizations"
terraform_distribution = "oss"
}
Once AFT is live, vending an account is a pull request to the account request repo. Each account is a module call:
module "team_payments_prod" {
source = "./modules/aft-account-request"
control_tower_parameters = {
AccountEmail = "aws+payments-prod@kloudvin.io"
AccountName = "payments-prod"
ManagedOrganizationalUnit = "Prod (ou-xxxx-prod1234)"
SSOUserEmail = "platform@kloudvin.io"
SSOUserFirstName = "Platform"
SSOUserLastName = "Team"
}
account_tags = {
"team" = "payments"
"environment" = "prod"
"cost-center" = "CC-4012"
}
# Which customization sets run after provisioning
account_customizations_name = "workload-prod"
change_management_parameters = {
change_requested_by = "vinod"
change_reason = "New prod account for payments service"
}
}
Merge to the main branch, and AFT’s pipeline calls Account Factory to create and enroll the account, then runs your customization layers against it — no console, full audit trail, fully reproducible.
Step 5 — Customizations: baking VPCs, IAM roles, and CloudTrail into every account
AFT applies customizations in two tiers, and understanding the order is the key to a clean baseline:
- Global customizations — Terraform that runs on every account AFT touches. Put your universal baseline here: a standard VPC pattern, break-glass IAM roles, an account-level GuardDuty/Config posture, default budgets, mandatory tags.
- Account customizations — named bundles (e.g.
workload-prod,sandbox) selected per request. Put environment-specific bits here: a larger VPC CIDR for prod, stricter password policy, prod-only backup vaults.
A global customization that lays down a standard network and a cross-account automation role looks like ordinary Terraform — AFT just runs it in the target account:
# aft-global-customizations/terraform/network.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.13.0"
name = "core"
cidr = var.vpc_cidr
azs = ["${var.region}a", "${var.region}b", "${var.region}c"]
private_subnets = var.private_subnets
public_subnets = var.public_subnets
enable_nat_gateway = true
single_nat_gateway = var.environment != "prod"
enable_dns_hostnames = true
}
# A standard role the platform pipeline assumes into this account
resource "aws_iam_role" "platform_automation" {
name = "PlatformAutomation"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { AWS = "arn:aws:iam::${var.aft_mgmt_account_id}:root" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = { "sts:ExternalId" = var.automation_external_id }
}
}]
})
}
Note: you generally do not create a per-account CloudTrail trail in customizations. The organization trail (Step 6) already captures every account centrally; a redundant per-account trail just duplicates data and cost. Reserve account-level trails for narrow cases like a data-events trail scoped to one sensitive account.
Step 6 — Centralized logging: the CloudTrail org trail to Log Archive
Control Tower provisions an organization CloudTrail trail that captures management events across all accounts and delivers them to a hardened S3 bucket in the Log Archive account. The org trail is created in the management account but applies org-wide, so a new account is covered the moment it’s enrolled — no per-account wiring.
The properties that make this trustworthy:
- The destination bucket lives in Log Archive, a different account from where workloads run, so a workload-account compromise cannot delete its own audit trail.
- Mandatory controls deny changes to the trail, the bucket, and the delivery roles from member accounts.
- Log file validation produces tamper-evident digests.
For long-term integrity, harden the bucket with S3 Object Lock (write-once-read-many) and a lifecycle policy. Verify CloudTrail integrity from a role in the audit account:
# Validate that delivered log files haven't been tampered with
aws cloudtrail validate-logs \
--trail-arn "arn:aws:cloudtrail:us-east-1:111111111111:trail/aws-controltower-BaselineCloudTrail" \
--start-time "2026-05-01T00:00:00Z"
If you need queryable history, point Athena or CloudTrail Lake at the archive bucket rather than standing up logging stacks in every account.
Enterprise scenario
A fintech platform team running ~140 accounts under AFT hit a wall during a routine landing-zone upgrade. The new baseline version (Control Tower 3.x) shipped a stricter mandatory control on the org CloudTrail’s KMS key policy. After they clicked Update landing zone, every subsequent AFT account-customization run started failing at terraform apply with AccessDenied on kms:GenerateDataKey — but only in accounts vended before the upgrade. New accounts were fine. The root cause: the in-place landing-zone update updates the managed baseline, but it does not re-apply the baseline to already-enrolled OUs. The pre-existing accounts were sitting in a drifted state against the new control set, and their CodeBuild execution role could no longer write encrypted logs.
The fix was not to hand-edit the KMS policy (that just creates more drift Control Tower fights back). They re-registered each affected OU to push the new baseline down, then let AFT reconcile:
# Re-apply the current baseline to a drifted OU so enrolled accounts converge
aws controltower enable-baseline \
--baseline-identifier "$AWS_CONTROL_TOWER_BASELINE_ARN" \
--target-identifier "$WORKLOADS_PROD_OU_ARN" \
--baseline-version "4.0" \
--parameters '[{"key":"IdentityCenterEnabledBaselineArn","value":"'"$IC_BASELINE_ARN"'"}]'
# Then list operations to confirm the OU baseline op reached SUCCEEDED
aws controltower list-baseline-operations \
--query 'baselineOperations[0].{Op:operationType,Status:status}'
The lasting lesson: treat update landing zone and re-register OUs as one atomic runbook step, gated in change management. Upgrading the baseline without re-registering OUs leaves your existing fleet quietly non-compliant until something downstream — usually a pipeline — trips over it.
Verify
Confirm the foundation is actually wired up — not just that the console said “success”:
# 1. Organizations is in ALL features mode (required for SCPs/Control Tower)
aws organizations describe-organization \
--query 'Organization.FeatureSet' # -> "ALL"
# 2. Your foundational + custom accounts exist and are ACTIVE
aws organizations list-accounts \
--query 'Accounts[].{Name:Name,Status:Status}' --output table
# 3. SCPs are enabled as a policy type at the root
aws organizations list-roots \
--query 'Roots[0].PolicyTypes' # SERVICE_CONTROL_POLICY -> ENABLED
# 4. The org CloudTrail trail is multi-region and logging
aws cloudtrail describe-trails \
--query 'trailList[?IsOrganizationTrail==`true`].[Name,IsMultiRegionTrail]' \
--output table
# 5. A vended account landed in the right OU (run after an AFT merge)
aws organizations list-parents --child-id "$NEW_ACCOUNT_ID"
Then sanity-check governance the way an auditor would: assume into a workload account and try a forbidden action (e.g. create a resource in a non-approved region). A correctly applied SCP returns AccessDenied regardless of IAM permissions.
Landing-zone checklist
Drift, upgrades, and decommissioning
Drift. Control Tower detects when an enrolled account diverges from its baseline (a control disabled out-of-band, an account moved between OUs manually, the Config recorder stopped). The dashboard surfaces it; remediate by re-registering the OU or re-enrolling the account rather than hand-patching. Never edit Control Tower’s managed roles, SCPs, or trail directly — that creates drift.
Landing-zone upgrades. Control Tower ships new landing zone versions that add or change baseline behavior. You explicitly update the landing zone and then re-register/update OUs so the new baseline reaches existing accounts. Read the release notes before upgrading — new mandatory controls can break workflows that relied on previously-allowed APIs.
Decommissioning a workload account. There is no instant “delete.” The runbook:
- Move the account to the Suspended OU and attach a deny-all SCP to freeze it.
- Drain it — back up anything needed to the archive, then destroy workload resources via Terraform.
- Unmanage the account from Account Factory (remove its AFT request) so AFT stops reconciling it.
- Close the account through Organizations (
aws organizations close-account --account-id ...). It enters a suspended state for ~90 days before AWS permanently deletes it.
Pitfalls
- Wrong home region. Chosen once, hard to undo. Pick your primary region deliberately.
- Region-lock SCPs without exemptions. Forgetting to exempt IAM/Route 53/CloudFront/
stslocks you out. Always use theNotActionallowlist. - Workloads in the management account. It must stay clean — it’s the one account that can dismantle everything.
- Fighting mandatory controls. If a workflow needs an action a mandatory control blocks, redesign the workflow; don’t try to subvert the guardrail.
- Per-account CloudTrail sprawl. The org trail already covers everything. Redundant trails just multiply S3 and ingestion cost.
- Hand-editing managed resources. Touching Control Tower’s roles, SCPs, or buckets creates drift the platform will flag and fight.
With this foundation, vending the hundredth account is the same PR as the first — governed, logged, and consistent on day one.