2026-07-30 · 8 min read
Setting Up AWS Organizations with SCPs and Centralized Logging
How to build a multi-account AWS foundation with Terraform, Organizations, OUs, account vending, preventative SCPs, and a tamper-proof centralized CloudTrail.

Most teams start with a single AWS account. It works, until it doesn't. IAM boundaries blur, billing is impossible to attribute, and one bad deployment can take down everything including your audit trail.
A multi-account strategy fixes all of this, and AWS Organizations is the control plane that makes it manageable. In this post I walk through the foundation I built with Terraform: an Organization with structured OUs, automated account vending, three preventative Service Control Policies, and a centralized tamper-proof CloudTrail that no individual account can disable or modify.
Why Multi-Account Matters
A single AWS account gives you a single blast radius, one compromised set of credentials can reach production databases, delete CloudTrail logs, and spin up crypto miners on the same invoice.
Separate accounts give you:
- Hard security boundaries: IAM policies cannot cross account lines without explicit trust
- Blast-radius isolation: a runaway workload in dev cannot saturate prod network quotas
- Cost attribution: each account maps 1:1 to a cost center or team
- Compliance separation: PCI/SOC workloads live in accounts with tighter controls
- Independent service quotas: dev experimentation never exhausts production limits
AWS Organizations lets you govern all of these accounts from a single management account, applying guardrails top-down via Service Control Policies.
OU Hierarchy Design
A flat list of accounts becomes chaos fast. Organizational Units (OUs) let you group accounts and attach policies at the right level of granularity.
Here's the hierarchy I use:
Root
├── Security # Log Archive, Audit/Security Tooling
├── Infrastructure # Shared Services, Networking Hub
├── Workloads
│ ├── Production # Prod workload accounts
│ └── Non-Production # Dev, Staging, Sandbox
└── Suspended # Quarantine for compromised/decommissioned accounts
Key decisions:
- Security OU at the top: the Log Archive account is where centralized CloudTrail lands. It has the most restrictive SCPs.
- Separate Prod / Non-Prod: different SCPs apply (e.g., region restrictions are tighter in prod).
- Suspended OU: instead of deleting accounts (which AWS makes difficult), move compromised accounts here with a deny-all SCP attached.
In Terraform this maps to nested aws_organizations_organizational_unit resources:
resource "aws_organizations_organization" "org" {
feature_set = "ALL"
enabled_policy_types = [
"SERVICE_CONTROL_POLICY",
]
}
resource "aws_organizations_organizational_unit" "security" {
name = "Security"
parent_id = aws_organizations_organization.org.roots[0].id
}
resource "aws_organizations_organizational_unit" "workloads" {
name = "Workloads"
parent_id = aws_organizations_organization.org.roots[0].id
}
resource "aws_organizations_organizational_unit" "workloads_prod" {
name = "Production"
parent_id = aws_organizations_organizational_unit.workloads.id
}
resource "aws_organizations_organizational_unit" "workloads_nonprod" {
name = "Non-Production"
parent_id = aws_organizations_organizational_unit.workloads.id
}
Account Vending with Terraform
Creating accounts manually through the console is error-prone and unrepeatable. Terraform's aws_organizations_account resource turns account creation into a code review:
resource "aws_organizations_account" "prod_api" {
name = "prod-api"
email = "aws+prod-api@company.com"
parent_id = aws_organizations_organizational_unit.workloads_prod.id
role_name = "OrganizationAccountAccessRole"
lifecycle {
ignore_changes = [role_name]
}
tags = {
Environment = "production"
Team = "platform"
}
}
Tips for account vending:
- Use
+email aliases: Gmail and most providers routeaws+prod-api@company.comto the same inbox, giving each account a unique root email. - Set
role_name: this cross-account role lets the management account bootstrap the new account with baseline resources. - Tag consistently: tags flow into Cost Explorer for attribution.
- Use
for_eachover a map: when you have many accounts, define them in a local map and iterate to reduce boilerplate.
The 3 Key SCPs
Service Control Policies are permission boundaries that apply to every principal in the attached accounts, including root. They don't grant permissions; they restrict what's possible. Think of them as guardrails, not grants.
1. Deny Leaving the Organization
If an account leaves the Organization, all governance disappears instantly. This SCP makes that impossible:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyLeaveOrganization",
"Effect": "Deny",
"Action": [
"organizations:LeaveOrganization"
],
"Resource": "*"
}
]
}
Attach this to the Root, every account in the Organization inherits it. Even a compromised root user in a member account cannot detach from governance.
2. Protect CloudTrail
A sophisticated attacker's first move is to disable logging. This SCP prevents any member account from stopping, deleting, or modifying the organization trail:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyCloudTrailModification",
"Effect": "Deny",
"Action": [
"cloudtrail:DeleteTrail",
"cloudtrail:StopLogging",
"cloudtrail:UpdateTrail",
"cloudtrail:PutEventSelectors"
],
"Resource": "arn:aws:cloudtrail:*:*:trail/organization-trail",
"Condition": {
"StringNotEqualsIgnoreCase": {
"aws:PrincipalOrgMasterAccountId": "${aws:PrincipalAccount}"
}
}
}
]
}
The condition ensures only the management account (which creates the org trail) can modify it. Every other account, including their root users, is blocked.
3. Region Restriction
Limiting workloads to approved regions reduces attack surface and simplifies compliance. This SCP denies all actions outside your chosen regions, with exceptions for global services:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyUnapprovedRegions",
"Effect": "Deny",
"NotAction": [
"a4b:*",
"budgets:*",
"ce:*",
"cloudfront:*",
"globalaccelerator:*",
"iam:*",
"importexport:*",
"organizations:*",
"route53:*",
"sts:*",
"support:*",
"waf:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": [
"eu-west-1",
"eu-central-1",
"us-east-1"
]
}
}
}
]
}
NotAction exempts global services (IAM, Route 53, CloudFront, etc.) that only operate from us-east-1 regardless of where you call them. Without this exemption, basic operations would break.
I attach this to the Workloads OU so the Security OU retains flexibility for cross-region log replication.
Attaching SCPs in Terraform
resource "aws_organizations_policy" "deny_leave_org" {
name = "deny-leave-organization"
description = "Prevent accounts from leaving the Organization"
type = "SERVICE_CONTROL_POLICY"
content = file("policies/deny-leave-org.json")
}
resource "aws_organizations_policy_attachment" "deny_leave_org_root" {
policy_id = aws_organizations_policy.deny_leave_org.id
target_id = aws_organizations_organization.org.roots[0].id
}
Centralized CloudTrail (Tamper-Proof)
An organization trail logs API activity from every member account into a single S3 bucket in the Log Archive account. The key is making that bucket tamper-proof, even if a member account is compromised, the attacker cannot delete or overwrite historical logs.
The Organization Trail
resource "aws_cloudtrail" "org_trail" {
name = "organization-trail"
s3_bucket_name = aws_s3_bucket.cloudtrail_logs.bucket
is_organization_trail = true
is_multi_region_trail = true
include_global_service_events = true
enable_log_file_validation = true
event_selector {
read_write_type = "All"
include_management_events = true
}
}
enable_log_file_validation = true creates digest files that let you verify no log was tampered with after delivery.
Tamper-Proof S3 Bucket Policy
The bucket lives in the Log Archive account. Its policy allows CloudTrail to write but denies deletion from anyone except a break-glass role:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudTrailWrite",
"Effect": "Allow",
"Principal": {
"Service": "cloudtrail.amazonaws.com"
},
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::org-cloudtrail-logs-123456789012/*",
"Condition": {
"StringEquals": {
"s3:x-amz-acl": "bucket-owner-full-control",
"aws:SourceOrgID": "o-abc123def4"
}
}
},
{
"Sid": "DenyLogDeletion",
"Effect": "Deny",
"Principal": "*",
"Action": [
"s3:DeleteObject",
"s3:DeleteObjectVersion",
"s3:PutLifecycleConfiguration"
],
"Resource": "arn:aws:s3:::org-cloudtrail-logs-123456789012/*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalArn": "arn:aws:iam::111111111111:role/BreakGlassAdmin"
}
}
}
]
}
Additional hardening:
- S3 Object Lock (Compliance mode): even the bucket owner cannot delete objects until the retention period expires.
- Versioning enabled: overwritten objects are preserved as previous versions.
- Block Public Access: all four settings enabled at the bucket level.
- KMS encryption: a CMK in the Log Archive account encrypts all objects; the key policy restricts decrypt to the security team.
Lessons Learned
Start with SCPs before workloads. Retrofitting guardrails onto existing accounts is painful: teams have already built in denied regions or rely on APIs you want to restrict. Apply SCPs to empty OUs first, then move accounts in.
The management account is special. SCPs do not apply to the management account. Keep it clean: no workloads, no developer access. Use it only for Organizations administration and billing.
Test SCPs in Non-Production first. Attach new policies to the Non-Prod OU and let them bake for a week. Watch for AccessDenied errors in CloudTrail before promoting to Production.
Account email matters. AWS requires a unique email per account and uses it for root password resets. Use a distribution list or shared mailbox: never a personal email.
Plan for the Suspended OU. When incidents happen, you need a quarantine path ready. A deny-all SCP on the Suspended OU instantly freezes an account without deleting anything.
Wrapping Up
A multi-account AWS foundation isn't optional for production workloads, it's table stakes. With Terraform you can version-control the entire structure: the Organization, OUs, accounts, SCPs, and centralized logging. Changes go through code review, and drift is detectable.
The combination of preventative SCPs and tamper-proof centralized CloudTrail means that even if an individual account is fully compromised, the attacker cannot erase their tracks or escape governance.
The full Terraform code, including all policies, the org trail, and the hardened S3 bucket, is open source: