Creating a New AWS Account with CDK
Overview
This runbook covers provisioning a new AWS account in the org — a workload environment, a shared infrastructure account, or a personal sandbox — and wiring it into SSO so people can actually sign in. Accounts are created exclusively through CDK pull requests, never through the AWS Console (Control Tower Account Factory / Service Catalog). Creating one by hand causes drift against our IaC and has caused root-email collisions in the past.
If you just want a personal sandbox, see Setting up a Sandbox AWS Account for the abbreviated, convention-locked version of the same two steps below. This runbook is the general procedure both build on.
When to use this
- You need a brand-new AWS account under the org — a new product/environment workload, a shared infra account, or a sandbox — provisioned and wired into SSO.
- Not for changing access on an account that already exists — see AWS Identity Center — Groups, Users & Permission Sets.
- Not for in-account IAM (roles/users inside an account's own IAM) — this is about AWS Organizations account objects and Identity Center, one layer up.
Repo map
| Repo | Owns | Deploys to |
|---|---|---|
AdAction/aws-organization-management | Org/OU structure, the account objects themselves, the StackSet that auto-deploys OrgAuditReadOnlyRole | Management account 172122050326, us-east-2 |
AdAction/aws-sso | Importing the account into SSO, Group → PermissionSet → Account assignments | Management account 172122050326, us-east-2 |
aws-organization-management exports the new account's ID via CloudFormation
(adaction-<account-name>-AccountId); aws-sso imports it with Fn.importValue.
Step 1 must merge, release, and deploy before Step 2 can reference it — the
export has to exist first, or the aws-sso deploy fails outright.
Prerequisites
- Push access to
AdAction/aws-organization-managementandAdAction/aws-sso, and a GitHub PAT withread:packagesscope configured in~/.npmrc— a plainnpm install404s on the@adaction/*scoped packages without it. - Node/npm matching each repo's
.nvmrc/engines. - An AWS CLI profile for the management account (
172122050326, profileadgemlocally) if you want to runcdk diff/cdk bootstrap— actual stack deploys happen only through CI. - At least one approving PR reviewer (generic branch-protection rule; neither repo has a stricter CODEOWNERS gate for this today).
Conventions
Naming
- Personal sandboxes:
sandbox-<username>-<n>, e.g.sandbox-bgiese-1. This format is load-bearing —isSandboxAccount()inaws-ssopattern-matches on thesandbox-prefix to route the account intoSandboxAssignmentStackinstead of the general one. - Everything else: a descriptive product/purpose name, usually with an
environment suffix —
ad-suite-production,data-engineering-prod,servicehub-stage-env,offerwall-ui-prod. There's no single enforced pattern beyond "descriptive and environment-qualified" — checkbuildNewAccounts()inaccounts-stack.tsandIMPORTED_ACCOUNTSinorg-inventory.tsfor existing names before picking one.
Root email
aws+<account-name>@adaction.com — always the shared aws@ alias with
plus-addressing, never a personal email. AWS account emails are globally unique and
are the root user's recovery channel; keeping them all on the aws+ alias means
accounts-stack.ts doubles as a partial inventory of addresses already used. It
isn't a complete record, though — closed accounts and accounts outside our org won't
show up there.
accounts-stack.ts deliberately strips Teams, Service, Product, Environment,
and Owners off every CfnAccount (stripCostAllocationTags()). Tags on an
Organizations account object are inherited by every CUR line item billed to that
account — an account tagged Teams=platform silently double-attributes (or
mis-attributes) all spend in it, including line items that already carry their own
correct resource-level tag. See PLA-165 and PLA-399. If you need those dimensions for
Datadog Cloud Cost Management, tag the resources inside the account, not the account
object itself.
OU hierarchy
Defined in organizational-structure-stack.ts. Every new account needs an OU
assignment; don't invent a new top-level OU without a CDK change there first.
| OU path | Used for |
|---|---|
Security | Security tooling accounts |
Workloads/Prod | Production workload accounts (ad-suite-production, adaction-docs, adgem-docs) |
Workloads/Sdlc | Non-prod workload environments |
Infrastructure/Prod | Shared production infra (cognito-production, mcp-server, data-archive) |
Infrastructure/SDLC | Shared non-prod infra |
Suspended | Decommissioned/closed-out accounts |
Deployments | Deployment-pipeline accounts |
Sandbox | Personal sandbox accounts |
Credentials | Credential-related accounts |
Step 1 — create the account (aws-organization-management)
-
Add the account to
buildNewAccounts()insrc/stacks/accounts-stack.ts:{name: 'my-new-account',email: 'aws+my-new-account@adaction.com',}, -
Add the OU assignment to
ACCOUNT_OU_ASSIGNMENTSinsrc/stacks/accounts-ou-assignment-stack.ts:{ accountName: 'my-new-account', ouPath: 'Workloads/Prod' },
If the account was created outside CDK (or predates it), it goes in
IMPORTED_ACCOUNTS in src/data/org-inventory.ts instead of buildNewAccounts() —
CloudFormation fails with AlreadyExists otherwise. org-inventory.ts entries are
imported with a RETAIN deletion policy and referenced positionally; append only,
never reorder.
-
Nothing else to do for baseline auditability: once the account lands in any OU, a service-managed CloudFormation StackSet (
stacksets-stack.ts) auto-deploys theOrgAuditReadOnlyRoleto it — that's org-wide and automatic, not something you configure per account. -
Verify, PR, deploy:
npm run build && npm test && npx cdk diffConfirm the diff shows a
Createfor the newCfnAccount. Open a PR with a Conventional Commits title —feat:orfix:(semantic-release only deploys on those, notchore:). On merge,deploy-iac.ymldeploys viaAdAction/shared-workflowsand publishes theadaction-my-new-account-AccountIdexport that Step 2 depends on.
Step 2 — wire it into SSO (aws-sso)
-
Add one line to
importAccounts()insrc/import-utils.ts— only after Step 1 has deployed:this.importAccount({ name: "my-new-account" }), -
Accounts named
sandbox-*route automatically intoSandboxAssignmentStackviaisSandboxAccount(). Anything else that needs sandbox-style treatment (rare — a few legacy exceptions are hardcoded in that function) needs an explicit entry there. -
The
platformgroup gets every permission set on every imported account automatically — no action needed for that. If a specific team's group needs access to this new account, add it to that team'sassign<TeamName>Permissions()method inupdated-assignment-stack.ts. Full pattern and gotchas: AWS Identity Center — Groups, Users & Permission Sets.
Every group→permission-set→account assignment provisions a separate IAM role
(AWSReservedSSO_<PermissionSet>_<id>) in the target account, and the permission-set
catalog is capped at 10 managed policies each (repo guardrail, enforced by a Jest
test — see the Identity Center runbook for the full quota picture). A brand-new
account starts clean, but sandbox accounts fan out to every permission set via the
platform group's automatic grant and accumulate roles the fastest of any account
type.
-
Verify, PR, deploy:
npm run build && npm test && npx cdk diffPR with a
feat:/fix:title, ≥1 approval, merge after Step 1's release has deployed.semantic-releasecuts a release, which triggersdeploy.yml(cdk deploy --all --require-approval never).
Step 3 — bootstrap the account for CDK use
Only needed if this account will run its own CDK stacks (most workload/infra accounts will; a pure sandbox you're just poking around in might not).
-
Log in to AWS SSO with the AWS CLI:
aws configure sso --profile my-new-accountSSO start URL [None]: https://adgem.awsapps.com/startSSO Region [None]: us-east-2A browser opens for the authorization prompt — click Allow. (On AWS CLI versions before 2.22 you'll instead see a device code to enter manually; force that behavior with
--use-device-codeif needed.) -
Bootstrap the account, using the account ID from Step 1's export:
cdk bootstrap aws://<account-id>/us-east-2 --profile my-new-account
Accessing the account
Once both stacks have deployed, the account shows up in the
AWS access portal for anyone whose group carries a
permission set on it (automatically platform; explicitly-wired teams per Step 2).
Troubleshooting
- "An AWS account with that email already exists" — the
aws+<name>@email is taken, possibly by an account outside our org or a closed account (AWS reserves closed-account emails for ~90 days). Pick a different name/suffix;accounts-stack.tsandorg-inventory.tslist names we've used but can't show closed or foreign-org accounts. aws-ssodeploy fails on a missing export — Step 1 hasn't deployed yet. Wait for theaws-organization-managementrelease to finish, then re-run the failedaws-ssodeploy.- A team can see the account in the portal but with the wrong (or no) access —
the
platformgroup's automatic grant doesn't extend to other groups; someone needs to add the account to that team's method inupdated-assignment-stack.ts(Step 2, part 3). cdk deploy/cdk bootstrapfails for a stack in the new account — confirm Step 3 actually ran; a fresh account has no CDK bootstrap stack until someone bootstraps it.
Related docs
- Setting up a Sandbox AWS Account — the fast path for a personal sandbox specifically.
- AWS Identity Center — Groups, Users & Permission Sets — full detail on the Group/PermissionSet/assignment machinery once the account exists.
- ADR 0035: Programmatic Organizational Structure Management via AWS CDK — why this is all CDK instead of console-managed.