Build a Multi-Account AWS Landing Zone with AWS Organizations
Build a multi-account AWS landing zone with Organizations, guardrail SCPs, a tamper-proof org CloudTrail, GuardDuty and StackSets, with proof it works.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project builds an AWS multi-account landing zone with AWS Organizations: a management account, an organizational unit (OU) tree for security, logging and workloads, four member accounts, Service Control Policies that no account administrator can override, an organization-wide CloudTrail that writes to a locked-down log archive bucket, GuardDuty managed from a security account, and a CloudFormation StackSet that rolls baseline roles and budgets out to every workload account. It exists so that a mistake or a compromise in one account cannot spread to the others, and so every API call is recorded somewhere the workload teams cannot erase.
Why separate accounts instead of separate VPCs
A fintech like Razorpay with product, data and platform teams cannot rely on VPCs for isolation. IAM permissions are evaluated per account, not per VPC, so an over-broad role in a "dev" VPC can still read a production S3 bucket in the same account. Billing is also one number per account, so "what does the data team cost us" becomes a tagging exercise nobody keeps up with.
Separate accounts fix both problems:
- Each account has its own IAM namespace. A role in Dev has no access to Prod unless you create an explicit cross-account trust.
- A breach is contained by default, because sibling accounts do not trust each other.
- Cost, budgets and quotas are per account, with no tagging discipline needed.
A Service Control Policy (SCP) is a JSON policy attached to the root, an OU or an account. It sets the maximum permissions anyone in that account can ever have. It never grants anything, it only removes. Even a user with AdministratorAccess inside the account cannot do what an SCP denies. SCPs do not apply to the management account, which is why that account must never run workloads.
What you build, learn and can show
- You build: an organization with 4 member accounts, 3 SCPs, an organization trail with log file validation, GuardDuty in two regions, and one StackSet.
- You learn: how OUs and SCP inheritance work, how to stage a guardrail safely, why logs live in their own account, and how delegated administrators work.
- You can show: an architecture diagram, a repo of scripts, the SCP JSON with a one-line rationale for each, and a screenshot of a denied action proving the guardrail works.
The account structure
Management account"] --> S["Security OU
Security account"] R --> L["Logging OU
LogArchive account"] R --> W["Workloads OU"] W --> P["Production OU
Prod account"] W --> N["Non-Production OU
Dev account"] classDef mgmt fill:#475569,color:#FFFFFF classDef sec fill:#8B5CF6,color:#FFFFFF classDef work fill:#326CE5,color:#FFFFFF class R mgmt class S,L sec class W,P,N work
| Account | OU | Purpose |
|---|---|---|
| Management | Root | Billing, creating accounts, attaching SCPs. Runs no workloads |
| Security | Security | GuardDuty administrator, receives findings from every account |
| LogArchive | Logging | Holds the CloudTrail bucket nobody can delete from |
| Development | Non-Production | Safe place to test every guardrail first |
| Production | Production | Live workloads, strictest controls |
Prerequisites and cost
- A fresh AWS account to act as the management account, with AWS CLI v2 configured as an administrator and
jqinstalled. - Four unused email addresses. Plus-addressing works with most providers, for example
you+lz-dev@example.com. - Organizations and SCPs are free. The first copy of management events in CloudTrail is free, GuardDuty has a 30-day trial and then bills by usage, and S3 storage here is tiny. Check current pricing before you start.
TipSave every command you run into a
scripts/folder as you go. A landing zone you can recreate from scripts is a far stronger portfolio piece than one you clicked together.
Milestone 1: Create the Organization and Enable Trusted Access
This milestone turns your account into the management account and lets the AWS services you use later work across all member accounts. Without trusted access, an organization trail and a service-managed StackSet cannot see your other accounts.
export AWS_REGION=ap-south-1export AWS_DEFAULT_REGION=ap-south-1 aws sts get-caller-identity # ALL features is required for SCPs. The billing-only feature set cannot use themaws organizations create-organization --feature-set ALL ROOT_ID=$(aws organizations list-roots --query 'Roots[0].Id' --output text)ORG_ID=$(aws organizations describe-organization --query 'Organization.Id' --output text)MGMT_ID=$(aws sts get-caller-identity --query Account --output text) # Confirm SCPs are enabled. Expect SERVICE_CONTROL_POLICY with status ENABLEDaws organizations describe-organization --query 'Organization.AvailablePolicyTypes'Allow CloudTrail and StackSets to work across accounts:
aws organizations enable-aws-service-access --service-principal cloudtrail.amazonaws.comaws cloudformation activate-organizations-accessYour shell variables disappear when the terminal closes, so write them to a file you can source at the start of every session:
cat > landing-zone.env << EOFexport AWS_REGION=ap-south-1export AWS_DEFAULT_REGION=ap-south-1export ROOT_ID=$ROOT_IDexport ORG_ID=$ORG_IDexport MGMT_ID=$MGMT_IDEOFecho "landing-zone.env" >> .gitignoreCommon MistakeRunning every later command in a new terminal without sourcing
landing-zone.env. You will get empty IDs and confusing errors such as "parent-id is required". Runsource landing-zone.envfirst.
Milestone 2: Build the OU Tree
An OU is a folder of accounts. SCPs attach to OUs and every account inside inherits them, so the tree should mirror which guardrails apply to which accounts, not your org chart.
source landing-zone.env create_ou() { aws organizations create-organizational-unit \ --parent-id "$1" --name "$2" \ --query 'OrganizationalUnit.Id' --output text} SECURITY_OU=$(create_ou $ROOT_ID Security)LOGGING_OU=$(create_ou $ROOT_ID Logging)WORKLOADS_OU=$(create_ou $ROOT_ID Workloads) # Nesting lets you apply stricter SCPs to Production onlyPROD_OU=$(create_ou $WORKLOADS_OU Production)NONPROD_OU=$(create_ou $WORKLOADS_OU Non-Production) cat >> landing-zone.env << EOFexport SECURITY_OU=$SECURITY_OUexport LOGGING_OU=$LOGGING_OUexport WORKLOADS_OU=$WORKLOADS_OUexport PROD_OU=$PROD_OUexport NONPROD_OU=$NONPROD_OUEOFVerify the tree:
aws organizations list-organizational-units-for-parent \ --parent-id $ROOT_ID \ --query 'OrganizationalUnits[*].{Name:Name,Id:Id}' --output tableYou should see Security, Logging and Workloads.
Common MistakePutting Production and Non-Production directly under the root. Then a rule meant for "all workloads" has to be attached twice and will drift. Nest them under Workloads so one attachment covers both.
Milestone 3: Create the Member Accounts
Account creation is asynchronous, so this helper creates the account, polls until it is ready, and moves it into the right OU. Each new account gets its own root user, IAM namespace and an OrganizationAccountAccessRole that the management account can assume.
source landing-zone.env create_account() { local name="$1" email="$2" parent="$3" local req state id req=$(aws organizations create-account --account-name "$name" --email "$email" \ --query 'CreateAccountStatus.Id' --output text) state=IN_PROGRESS while [ "$state" = "IN_PROGRESS" ]; do sleep 10 state=$(aws organizations describe-create-account-status \ --create-account-request-id "$req" \ --query 'CreateAccountStatus.State' --output text) done [ "$state" = "SUCCEEDED" ] || { echo "Failed: $name ($state)" >&2; return 1; } id=$(aws organizations describe-create-account-status \ --create-account-request-id "$req" \ --query 'CreateAccountStatus.AccountId' --output text) aws organizations move-account --account-id "$id" \ --source-parent-id "$ROOT_ID" --destination-parent-id "$parent" echo "$id"} LOGARCHIVE_ID=$(create_account LogArchive "you+lz-logs@example.com" $LOGGING_OU)SECURITY_ID=$(create_account Security "you+lz-security@example.com" $SECURITY_OU)DEV_ID=$(create_account Development "you+lz-dev@example.com" $NONPROD_OU)PROD_ID=$(create_account Production "you+lz-prod@example.com" $PROD_OU) cat >> landing-zone.env << EOFexport LOGARCHIVE_ID=$LOGARCHIVE_IDexport SECURITY_ID=$SECURITY_IDexport DEV_ID=$DEV_IDexport PROD_ID=$PROD_IDEOF aws organizations list-accounts \ --query 'Accounts[*].{Name:Name,Id:Id,Status:Status}' --output tableSecurityIn a real company, use shared team aliases such as
aws-prod@yourcompany.com, never a personal address. The root email cannot be changed without access to that inbox, so an account tied to one person is lost when that person leaves.
Common MistakeHitting the default account quota on a new organization and seeing
ACCOUNT_LIMIT_EXCEEDED. New organizations start with a small account limit. Request an increase in Service Quotas if you plan to add more accounts.
Milestone 4: Attach Guardrail SCPs, Staged
Each SCP below removes one specific risk. You attach them to the Non-Production OU first and only promote them to Production after you have proven they behave, because a bad SCP at a high level can lock out your own team.
source landing-zone.env create_scp() { aws organizations create-policy --name "$1" --description "$2" \ --type SERVICE_CONTROL_POLICY --content "file://$3" \ --query 'Policy.PolicySummary.Id' --output text} # SCP 1: only use approved regions. Global services are excluded so IAM, STS and billing keep workingcat > scp-region-restriction.json << 'EOF'{ "Version": "2012-10-17", "Statement": [{ "Sid": "DenyOutsideApprovedRegions", "Effect": "Deny", "NotAction": [ "iam:*", "sts:*", "organizations:*", "route53:*", "route53domains:*", "cloudfront:*", "waf:*", "budgets:*", "ce:*", "support:*", "trustedadvisor:*", "health:*" ], "Resource": "*", "Condition": { "StringNotEquals": { "aws:RequestedRegion": ["ap-south-1", "ap-southeast-1"] } } }]}EOF # SCP 2: nobody can switch off the audit trailcat > scp-protect-cloudtrail.json << 'EOF'{ "Version": "2012-10-17", "Statement": [{ "Sid": "DenyCloudTrailTampering", "Effect": "Deny", "Action": [ "cloudtrail:DeleteTrail", "cloudtrail:StopLogging", "cloudtrail:UpdateTrail", "cloudtrail:PutEventSelectors" ], "Resource": "*" }]}EOF # SCP 3: no root user activity and no leaving the organizationcat > scp-workload-baseline.json << 'EOF'{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyRootUser", "Effect": "Deny", "Action": "*", "Resource": "*", "Condition": { "StringLike": { "aws:PrincipalArn": ["arn:aws:iam::*:root"] } } }, { "Sid": "DenyLeavingOrganization", "Effect": "Deny", "Action": "organizations:LeaveOrganization", "Resource": "*" } ]}EOF REGION_SCP=$(create_scp RestrictToApprovedRegions "Deny non-global actions outside approved regions" scp-region-restriction.json)TRAIL_SCP=$(create_scp ProtectCloudTrail "Deny disabling or editing CloudTrail" scp-protect-cloudtrail.json)BASELINE_SCP=$(create_scp WorkloadBaseline "Deny root user and leaving the org" scp-workload-baseline.json) cat >> landing-zone.env << EOFexport REGION_SCP=$REGION_SCPexport TRAIL_SCP=$TRAIL_SCPexport BASELINE_SCP=$BASELINE_SCPEOF # Stage 1: attach to Non-Production only. CloudTrail protection is low risk, so it goes to the rootaws organizations attach-policy --policy-id $REGION_SCP --target-id $NONPROD_OUaws organizations attach-policy --policy-id $BASELINE_SCP --target-id $NONPROD_OUaws organizations attach-policy --policy-id $TRAIL_SCP --target-id $ROOT_IDSCPs apply to every principal in the account, including your own pipelines. If a CI role needs a region you blocked, add a Condition that exempts that role, for example with aws:PrincipalArn.
Common MistakeAttaching a new SCP straight to the root. It affects every account at once and a typo can block IAM changes everywhere. Always attach to one test OU first, test, then widen the scope.
Milestone 5: Centralise Audit Logs with an Organization Trail
An organization trail records API activity from every account, present and future, into one S3 bucket. The bucket lives in the LogArchive account and denies deletes to everyone in the organization, so the evidence survives a compromise of any workload account.
First, a helper to work inside a member account by assuming the role Organizations created:
assume_role() { local creds creds=$(aws sts assume-role \ --role-arn "arn:aws:iam::$1:role/OrganizationAccountAccessRole" \ --role-session-name landing-zone \ --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text) read -r AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN <<< "$creds" export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN}unassume() { unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN; }Create and lock down the bucket from inside LogArchive:
source landing-zone.envassume_role $LOGARCHIVE_IDaws sts get-caller-identity --query Account --output text # should print the LogArchive ID BUCKET=org-trail-logs-$LOGARCHIVE_IDTRAIL_ARN=arn:aws:cloudtrail:ap-south-1:$MGMT_ID:trail/org-wide-audit-trail aws s3api create-bucket --bucket $BUCKET --region ap-south-1 \ --create-bucket-configuration LocationConstraint=ap-south-1 aws s3api put-public-access-block --bucket $BUCKET \ --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=trueaws s3api put-bucket-versioning --bucket $BUCKET --versioning-configuration Status=Enabled cat > trail-bucket-policy.json << EOF{ "Version": "2012-10-17", "Statement": [ { "Sid": "CloudTrailAclCheck", "Effect": "Allow", "Principal": { "Service": "cloudtrail.amazonaws.com" }, "Action": "s3:GetBucketAcl", "Resource": "arn:aws:s3:::$BUCKET", "Condition": { "StringEquals": { "aws:SourceArn": "$TRAIL_ARN" } } }, { "Sid": "CloudTrailWrite", "Effect": "Allow", "Principal": { "Service": "cloudtrail.amazonaws.com" }, "Action": "s3:PutObject", "Resource": [ "arn:aws:s3:::$BUCKET/AWSLogs/$MGMT_ID/*", "arn:aws:s3:::$BUCKET/AWSLogs/$ORG_ID/*" ], "Condition": { "StringEquals": { "s3:x-amz-acl": "bucket-owner-full-control", "aws:SourceArn": "$TRAIL_ARN" } } }, { "Sid": "NobodyInTheOrgCanDelete", "Effect": "Deny", "Principal": "*", "Action": ["s3:DeleteObject", "s3:DeleteObjectVersion"], "Resource": "arn:aws:s3:::$BUCKET/*", "Condition": { "StringEquals": { "aws:PrincipalOrgID": "$ORG_ID" } } } ]}EOF aws s3api put-bucket-policy --bucket $BUCKET --policy file://trail-bucket-policy.json # Lifecycle rules are run by S3 itself, so they still work despite the delete denyaws s3api put-bucket-lifecycle-configuration --bucket $BUCKET --lifecycle-configuration '{ "Rules": [{ "ID": "expire-old-logs", "Status": "Enabled", "Filter": { "Prefix": "AWSLogs/" }, "Expiration": { "Days": 400 }, "NoncurrentVersionExpiration": { "NoncurrentDays": 30 } }]}'unassumeecho "export BUCKET=$BUCKET" >> landing-zone.envNow create the trail from the management account:
aws cloudtrail create-trail \ --name org-wide-audit-trail \ --s3-bucket-name $BUCKET \ --is-organization-trail \ --is-multi-region-trail \ --enable-log-file-validation \ --region ap-south-1 aws cloudtrail start-logging --name org-wide-audit-trail --region ap-south-1Security
--enable-log-file-validationwrites a signed digest for every log file, so you can later prove logs were not altered. Many compliance frameworks expect this and it costs nothing extra. For stricter retention, S3 Object Lock is a good extension once you understand the lifecycle trade-offs.
Common MistakeCreating the trail before the bucket policy exists. CloudTrail then fails with
InsufficientS3BucketPolicyException. Apply the policy first, then create the trail.
Milestone 6: Aggregate Threat Findings with GuardDuty
GuardDuty watches for suspicious activity such as unusual API calls or crypto-mining behaviour. By making the Security account the delegated administrator, findings from every account appear in one place and new accounts are enrolled automatically. GuardDuty is regional, so you repeat the setup in each approved region.
source landing-zone.env # From the management account: delegate the Security account in each regionfor r in ap-south-1 ap-southeast-1; do aws guardduty enable-organization-admin-account \ --admin-account-id $SECURITY_ID --region $rdone # From inside the Security account: create a detector and auto-enroll every memberassume_role $SECURITY_IDfor r in ap-south-1 ap-southeast-1; do DET=$(aws guardduty list-detectors --region $r --query 'DetectorIds[0]' --output text) if [ "$DET" = "None" ]; then DET=$(aws guardduty create-detector --enable --region $r --query DetectorId --output text) fi aws guardduty update-organization-configuration \ --detector-id $DET --auto-enable-organization-members ALL --region $r echo "$r detector: $DET"doneunassumeMember enrolment can take a few minutes. Check it:
assume_role $SECURITY_IDDET=$(aws guardduty list-detectors --region ap-south-1 --query 'DetectorIds[0]' --output text)aws guardduty list-members --detector-id $DET --region ap-south-1 \ --query 'Members[*].[AccountId,RelationshipStatus]' --output tableunassumeTipThe management account is also a member. If it does not appear as
Enabled, that is the first thing to check when findings look incomplete.
Milestone 7: Roll Out Baseline Roles and Budgets with a StackSet
Re-running setup commands for every new account does not scale. A service-managed StackSet deploys the same CloudFormation template to every account in an OU, including accounts you add later. This template gives each workload account a read-only audit role for the Security account and a monthly cost budget, which is how you get per-account cost visibility.
cat > baseline.yaml << 'EOF'AWSTemplateFormatVersion: '2010-09-09'Description: Baseline for every workload account Parameters: SecurityAccountId: Type: String BudgetEmail: Type: String BudgetLimit: Type: Number Default: 50 Resources: SecurityAuditRole: Type: AWS::IAM::Role Properties: RoleName: SecurityAuditRole AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: AWS: !Sub 'arn:aws:iam::${SecurityAccountId}:root' Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/SecurityAudit - arn:aws:iam::aws:policy/job-function/ViewOnlyAccess MonthlyBudget: Type: AWS::Budgets::Budget Properties: Budget: BudgetName: account-monthly-cost BudgetType: COST TimeUnit: MONTHLY BudgetLimit: Amount: !Ref BudgetLimit Unit: USD NotificationsWithSubscribers: - Notification: NotificationType: ACTUAL ComparisonOperator: GREATER_THAN Threshold: 80 ThresholdType: PERCENTAGE Subscribers: - SubscriptionType: EMAIL Address: !Ref BudgetEmailEOF aws cloudformation validate-template --template-body file://baseline.yaml source landing-zone.envaws cloudformation create-stack-set \ --stack-set-name baseline-workload-account \ --template-body file://baseline.yaml \ --permission-model SERVICE_MANAGED \ --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false \ --capabilities CAPABILITY_NAMED_IAM \ --parameters ParameterKey=SecurityAccountId,ParameterValue=$SECURITY_ID \ ParameterKey=BudgetEmail,ParameterValue=you@example.com \ --region ap-south-1 # IAM is global, so deploy to a single region to avoid duplicate role namesaws cloudformation create-stack-instances \ --stack-set-name baseline-workload-account \ --deployment-targets OrganizationalUnitIds=$WORKLOADS_OU \ --regions ap-south-1 \ --operation-preferences FailureToleranceCount=0,MaxConcurrentCount=2 \ --region ap-south-1Common MistakeDeploying a template with a fixed IAM role name to two regions. IAM is global, so the second region fails with "already exists". Deploy IAM resources to one region only.
Milestone 8: Prove the Guardrails Work, Then Promote Them
An SCP that looks right in JSON may be attached to the wrong target or never take effect. Test with a real denied action, using the Development account as the safe test bed.
source landing-zone.envassume_role $DEV_ID # Allowed: an approved regionaws ec2 describe-instances --region ap-south-1 # Denied: a region outside the allow list, even though this role is an administratoraws ec2 describe-instances --region us-east-1 # Denied: stopping the organization trailaws cloudtrail stop-logging --name org-wide-audit-trail --region ap-south-1unassumeBoth denied calls should fail with a message that includes explicit deny in a service control policy. SCP changes can take a few minutes to apply, so retry after a short wait before assuming something is wrong.
Once you are happy, promote the two workload SCPs to Production:
aws organizations attach-policy --policy-id $REGION_SCP --target-id $PROD_OUaws organizations attach-policy --policy-id $BASELINE_SCP --target-id $PROD_OUTipScreenshot the denied call. It is the single most convincing artifact in this project because it shows a guardrail working, not just existing.
Validation & Testing
Run each check and compare with the expected result.
source landing-zone.env # 1. Four member accounts are ACTIVEaws organizations list-accounts --query 'Accounts[*].[Name,Status]' --output table # 2. Each account is in the right OU. Expected: Productionaws organizations list-accounts-for-parent --parent-id $PROD_OU --query 'Accounts[*].Name' --output text # 3. SCPs are attached where you expect. Expected: FullAWSAccess RestrictToApprovedRegions WorkloadBaselineaws organizations list-policies-for-target --target-id $PROD_OU \ --filter SERVICE_CONTROL_POLICY --query 'Policies[*].Name' --output text # 4. The organization trail is logging. Expected: trueaws cloudtrail get-trail-status --name org-wide-audit-trail --region ap-south-1 --query IsLogging # 5. Log file validation is on. Expected: trueaws cloudtrail describe-trails --trail-name-list org-wide-audit-trail --region ap-south-1 \ --query 'trailList[0].LogFileValidationEnabled' # 6. The StackSet reached every workload account. Expected: every row CURRENTaws cloudformation list-stack-instances --stack-set-name baseline-workload-account \ --region ap-south-1 --query 'Summaries[*].[Account,Status]' --output tableWithin about 15 minutes of enabling the trail, logs from the member accounts should appear under AWSLogs/ in the bucket. Check from the LogArchive account with aws s3 ls s3://$BUCKET/AWSLogs/ --recursive | head.
Cleanup
- Detach SCPs, then close the four member accounts with
aws organizations close-account --account-id ID. AWS limits how many accounts you can close in a rolling 30-day window, and closed accounts stay visible for a while. - Delete the StackSet instances and then the StackSet, and stop or delete the trail.
- The bucket policy denies deletes on purpose. To empty it, first remove the
NobodyInTheOrgCanDeletestatement from inside the LogArchive account. - Disable GuardDuty in both regions to stop charges.
Common MistakeForgetting that the management account is exempt from every SCP. If you test a guardrail from the management account it will appear not to work. Always test from a member account.
Commit the scripts with a README that includes your architecture diagram and the screenshot of the denied call. A reviewer can see that you understand account isolation, guardrails and tamper-proof logging.