Skip to main content

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

flowchart TD R["Org root
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 jq installed.
  • 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.
Tip

Save 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.

Bash
export AWS_REGION=ap-south-1
export AWS_DEFAULT_REGION=ap-south-1
aws sts get-caller-identity
# ALL features is required for SCPs. The billing-only feature set cannot use them
aws 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 ENABLED
aws organizations describe-organization --query 'Organization.AvailablePolicyTypes'

Allow CloudTrail and StackSets to work across accounts:

Bash
aws organizations enable-aws-service-access --service-principal cloudtrail.amazonaws.com
aws cloudformation activate-organizations-access

Your shell variables disappear when the terminal closes, so write them to a file you can source at the start of every session:

Bash
cat > landing-zone.env << EOF
export AWS_REGION=ap-south-1
export AWS_DEFAULT_REGION=ap-south-1
export ROOT_ID=$ROOT_ID
export ORG_ID=$ORG_ID
export MGMT_ID=$MGMT_ID
EOF
echo "landing-zone.env" >> .gitignore
Common Mistake

Running 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". Run source landing-zone.env first.

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.

Bash
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 only
PROD_OU=$(create_ou $WORKLOADS_OU Production)
NONPROD_OU=$(create_ou $WORKLOADS_OU Non-Production)
cat >> landing-zone.env << EOF
export SECURITY_OU=$SECURITY_OU
export LOGGING_OU=$LOGGING_OU
export WORKLOADS_OU=$WORKLOADS_OU
export PROD_OU=$PROD_OU
export NONPROD_OU=$NONPROD_OU
EOF

Verify the tree:

Bash
aws organizations list-organizational-units-for-parent \
--parent-id $ROOT_ID \
--query 'OrganizationalUnits[*].{Name:Name,Id:Id}' --output table

You should see Security, Logging and Workloads.

Common Mistake

Putting 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.

Bash
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 << EOF
export LOGARCHIVE_ID=$LOGARCHIVE_ID
export SECURITY_ID=$SECURITY_ID
export DEV_ID=$DEV_ID
export PROD_ID=$PROD_ID
EOF
aws organizations list-accounts \
--query 'Accounts[*].{Name:Name,Id:Id,Status:Status}' --output table
Security

In 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 Mistake

Hitting 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.

Bash
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 working
cat > 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 trail
cat > 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 organization
cat > 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 << EOF
export REGION_SCP=$REGION_SCP
export TRAIL_SCP=$TRAIL_SCP
export BASELINE_SCP=$BASELINE_SCP
EOF
# Stage 1: attach to Non-Production only. CloudTrail protection is low risk, so it goes to the root
aws organizations attach-policy --policy-id $REGION_SCP --target-id $NONPROD_OU
aws organizations attach-policy --policy-id $BASELINE_SCP --target-id $NONPROD_OU
aws organizations attach-policy --policy-id $TRAIL_SCP --target-id $ROOT_ID

SCPs 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 Mistake

Attaching 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:

Bash
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:

Bash
source landing-zone.env
assume_role $LOGARCHIVE_ID
aws sts get-caller-identity --query Account --output text # should print the LogArchive ID
BUCKET=org-trail-logs-$LOGARCHIVE_ID
TRAIL_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=true
aws 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 deny
aws 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 }
}]
}'
unassume
echo "export BUCKET=$BUCKET" >> landing-zone.env

Now create the trail from the management account:

Bash
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-1
Security

--enable-log-file-validation writes 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 Mistake

Creating 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.

Bash
source landing-zone.env
# From the management account: delegate the Security account in each region
for r in ap-south-1 ap-southeast-1; do
aws guardduty enable-organization-admin-account \
--admin-account-id $SECURITY_ID --region $r
done
# From inside the Security account: create a detector and auto-enroll every member
assume_role $SECURITY_ID
for 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"
done
unassume

Member enrolment can take a few minutes. Check it:

Bash
assume_role $SECURITY_ID
DET=$(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 table
unassume
Tip

The 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.

Bash
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 BudgetEmail
EOF
aws cloudformation validate-template --template-body file://baseline.yaml
source landing-zone.env
aws 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 names
aws 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-1
Common Mistake

Deploying 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.

Bash
source landing-zone.env
assume_role $DEV_ID
# Allowed: an approved region
aws ec2 describe-instances --region ap-south-1
# Denied: a region outside the allow list, even though this role is an administrator
aws ec2 describe-instances --region us-east-1
# Denied: stopping the organization trail
aws cloudtrail stop-logging --name org-wide-audit-trail --region ap-south-1
unassume

Both 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:

Bash
aws organizations attach-policy --policy-id $REGION_SCP --target-id $PROD_OU
aws organizations attach-policy --policy-id $BASELINE_SCP --target-id $PROD_OU
Tip

Screenshot 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.

Bash
source landing-zone.env
# 1. Four member accounts are ACTIVE
aws organizations list-accounts --query 'Accounts[*].[Name,Status]' --output table
# 2. Each account is in the right OU. Expected: Production
aws organizations list-accounts-for-parent --parent-id $PROD_OU --query 'Accounts[*].Name' --output text
# 3. SCPs are attached where you expect. Expected: FullAWSAccess RestrictToApprovedRegions WorkloadBaseline
aws 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: true
aws cloudtrail get-trail-status --name org-wide-audit-trail --region ap-south-1 --query IsLogging
# 5. Log file validation is on. Expected: true
aws 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 CURRENT
aws cloudformation list-stack-instances --stack-set-name baseline-workload-account \
--region ap-south-1 --query 'Summaries[*].[Account,Status]' --output table

Within 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 NobodyInTheOrgCanDelete statement from inside the LogArchive account.
  • Disable GuardDuty in both regions to stop charges.
Common Mistake

Forgetting 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.

Found a mistake, an outdated step or a broken link?