AWS CDK Landing Zone

Organization Structure

Define OUs, accounts, and SCP attachments for your AWS Organization in organization-structure.ts.

src/config/organization-structure.ts is where you define your entire AWS Organization hierarchy: which Organizational Units exist, which accounts belong to each OU, and which SCPs attach at the root, OU, or account level.

The OrganizationConstruct in the foundation package reads this object and creates or updates OUs, accounts, and SCP attachments on every deploy. It also publishes the resolved organization identifiers as CloudFormation stack outputs so later stacks can consume them as StackSet targets. OU IDs are published by default; account IDs are opt-in per account with publishAccountIdOutput.

Built-in OUs

The starter ships with six pre-configured OUs that match common AWS multi-account patterns:

OU keyDefault namePurpose
LogArchiveOUlog-ouHolds the log archive account that receives the centralized organization CloudTrail logs
SecurityOUsecurity-ouHolds the security account, the delegated administrator for CloudTrail, GuardDuty, Security Hub, Inspector, and Macie
InfrastructureOUinfrastructure-ouHolds the landing zone account that deploys the landing-zone StackSets as the StackSets delegated admin
DevelopmentOUworkload-dev-ouWorkload accounts for non-production environments, including the sandbox
ProductionOUworkload-prod-ouWorkload accounts for production environments
SuspendedOUsuspended-ouReceives the lockdownSuspendedAccountsSCP and nests the CloseAccountOU

SuspendedOU nests a CloseAccountOU (close-account-ou). The two model different offboarding states, covered in Suspended vs. close-account OU below.

You can rename, add, or remove OUs by editing the organizationalUnits map. The OU key (e.g. LogArchiveOU) becomes the stack output name (LogArchiveOUId) and the TypeScript property name on orgVars; changing a key is a compile-time-checked rename.

The SecurityAccount, LandingZoneAccount, and LogArchiveAccount keys are referenced by other stacks and delegated-admin config, so they set publishAccountIdOutput: true. Renaming those keys is a compile-time-checked change you will see reflected in delegated-admins.ts and the StackSet targeting.

Options

The OrganizationStructure type has a single required top-level key:

OptionTypeRequiredDescription
rootOrganizationRootStructureYesThe organization root node. Holds SCPs and the OU map.
root.serviceControlPoliciesServiceControlPolicy[]NoSCPs attached at the organization root (apply to every account). Max 10 per target.
root.organizationalUnitsRecord<string, OrganizationalUnitStructure>NoMap of OU key → OU definition.

Each OrganizationalUnitStructure entry:

OptionTypeRequiredDescription
namestringYesDisplay name of the OU in AWS Organizations.
serviceControlPoliciesServiceControlPolicy[]NoSCPs attached to this OU. Max 10 directly attached per target.
accountsRecord<string, AccountStructure>NoMap of account key → account definition.
organizationalUnitsRecord<string, OrganizationalUnitStructure>NoNested child OUs. The structure is recursive, so OUs can nest to any depth.

Each AccountStructure entry:

OptionTypeRequiredDescription
namestringYesDisplay name of the account in AWS Organizations.
emailstringYesRoot email address for the account. Must be globally unique across all of AWS.
publishAccountIdOutputbooleanNoPublish this account's ID as a stack output and typed orgVars.<Key>Id variable. Set it for accounts other stacks reference. Default false.
serviceControlPoliciesServiceControlPolicy[]NoSCPs attached to this specific account. Max 10 directly attached per target.

Example

const mailDomain = landingZoneSettings.mailDomain.toLowerCase();

export const organizationStructure = {
  root: {
    serviceControlPolicies: [
      criticalSecurityGuardrailsSCP,
      protectTaggedCloudFormationStacksSCP,
      denyAllOutsidePrimaryAndSecondaryRegionsSCP,
    ],
    organizationalUnits: {
      LogArchiveOU: {
        name: 'log-ou',
        accounts: {
          LogArchiveAccount: {
            name: 'logarchive-account',
            email: `aws+log@${mailDomain}`,
            publishAccountIdOutput: true,
          },
        },
      },
      SecurityOU: {
        name: 'security-ou',
        accounts: {
          SecurityAccount: {
            serviceControlPolicies: [protectSecurityHubConfigurationSCP],
            name: 'security-account',
            email: `aws+security@${mailDomain}`,
            publishAccountIdOutput: true,
          },
        },
      },
      InfrastructureOU: {
        name: 'infrastructure-ou',
        accounts: {
          LandingZoneAccount: {
            name: 'landingzone-account',
            email: `aws+landingzone@${mailDomain}`,
            publishAccountIdOutput: true,
          },
        },
      },
      DevelopmentOU: {
        name: 'workload-dev-ou',
        accounts: {
          SandboxAccount: {
            name: 'Sandbox',
            email: `sandbox@${mailDomain}`,
          },
        },
      },
      ProductionOU: {
        name: 'workload-prod-ou',
        accounts: {
          WorkloadAlphaAccount: {
            name: 'Workload Alpha',
            email: `aws+workload-alpha@${mailDomain}`,
          },
          WorkloadBetaAccount: {
            name: 'Workload Beta',
            // Account emails don't have to use the organization mail domain;
            // any unique, valid address works.
            email: 'team-beta+aws@example.org',
          },
        },
      },
      SuspendedOU: {
        name: 'suspended-ou',
        serviceControlPolicies: [lockdownSuspendedAccountsSCP],
        organizationalUnits: {
          // Moving an account here triggers the close-account automation.
          CloseAccountOU: {
            name: 'close-account-ou',
          },
        },
      },
    },
  },
} satisfies OrganizationStructure;

export type LandingZoneOrganizationStructure = typeof organizationStructure;

The OU and account name fields are the display names shown in the AWS Organizations console; they are plain strings, not derived from organizationName. The map keys (LogArchiveOU, SecurityAccount) are what become the typed orgVars.<Key>Id outputs.

Suspended vs. close-account OU

The structure distinguishes two offboarding states, and the CloseAccountOU nests inside SuspendedOU on purpose.

  • SuspendedOU isolates an account while keeping it. The lockdownSuspendedAccountsSCP denies every action except an allowlist of roles, and the OU is excluded from Security Hub CSPM, GuardDuty, and Macie member management. Use it to quarantine an account without deleting anything.
  • CloseAccountOU permanently closes an account. When this OU exists in the structure, its ID drives a scheduled reconcile in the organization phase (LandingZoneOrganizationStack, in the management account) that runs every 8 hours, lists the accounts in the OU, and closes any that are still active. Because it is a child of SuspendedOU, it inherits the lockdown SCP and the security-service exclusion, so an account waiting to close (or one whose closure fails against the Organizations quota of 10% of members per rolling 30 days) stays locked down and retries on the next run.

Closed accounts remain visible in the OU with SUSPENDED status for the 90-day post-closure window. If you do not want the automation at all, remove the CloseAccountOU from the structure; suspended accounts then simply stay isolated.

How it's used

The OrganizationConstruct in the organization phase (LandingZoneOrganizationStack) reads this object and:

  1. Creates the AWS Organization if it does not exist
  2. Creates OUs and accounts as specified
  3. Attaches SCPs at root, OU, and account levels
  4. Publishes every OU ID as a <Key>Id stack output, plus an account <Key>Id output for each account marked publishAccountIdOutput: true

The landing-zone stacks run from the landing zone account and read those outputs cross-account at deploy time, exposed to them as typed orgVars properties. Because the references resolve during deployment rather than at synth, there is nothing to cache first. LandingZoneOrganizationStack just has to be deployed before the stacks that consume its outputs.

Things to know

  • Account email addresses must be globally unique across all of AWS. The aws+<alias>@<domain> pattern is a reliable way to generate unique addresses under a single domain.
  • AWS Organizations limits SCPs to 10 directly attached per attachment point (root, OU, or account), raised from 5 to 10 in May 2026. Inherited SCPs do not count against a child's own quota. OrganizationConstruct validates the structure at synth time: it fails when a node exceeds 10 attached SCPs or a policy document exceeds 10,240 characters, and warns once you reach 8 attachments or roughly 90% of the document size, so quota problems surface before a CloudFormation rollback mid-deploy.
  • Account IDs are opt-in. OU IDs are always published, but an account only gets a <Key>Id output (and an orgVars.<Key>Id property) when you set publishAccountIdOutput: true. Reserve it for accounts other stacks reference by ID, such as the log archive and security accounts. A StackSet that targets a whole OU never needs one. This keeps large organizations under the CloudFormation per-stack output limit; synthesis warns as you approach it and fails before it is exceeded.
  • Renaming an account or OU key (e.g. WorkloadAlphaAccountAppAlphaAccount) changes the stack output name and breaks every stack that references orgVars.WorkloadAlphaAccountId. The TypeScript type LandingZoneOrganizationStructure makes these renames compile-time errors, so you'll catch them before deploy.
  • Removing an account from the structure does not close it. AWS Organizations does not allow programmatic account deletion, and the structure only manages membership. To decommission an account, move it to SuspendedOU to quarantine it, or to CloseAccountOU to have the close-account automation shut it down permanently.