The decisions you make in an AWS account's first month determine how painful the next three years are. Landing zone work is unglamorous, invisible when done well, and extremely expensive to retrofit.
This covers the foundations that are hard to change later: account structure, network address planning, identity, and guardrails.
Multiple accounts are the security boundary
The single most consequential decision. An AWS account is the strongest isolation boundary AWS offers — stronger than a VPC, stronger than IAM policy. Use it.
Root (management account — no workloads, ever)
├── Security OU
│ ├── log-archive immutable log destination
│ └── security-tooling GuardDuty, Security Hub, Detective
├── Infrastructure OU
│ ├── network-prod Transit Gateway, shared VPCs, DNS
│ └── network-nonprod
├── Workloads OU
│ ├── Prod OU
│ │ ├── payments-prod
│ │ └── platform-prod
│ └── NonProd OU
│ ├── payments-staging
│ └── payments-dev
└── Sandbox OU
└── sandbox-* per-engineer, budget-capped, auto-nuked
The pattern: one account per workload per environment. A compromised staging account cannot reach production. A runaway process in dev cannot exhaust a production service quota — quotas are per-account, and this alone justifies the structure.
The management account runs nothing. No workloads, no pipelines, no exceptions. It holds the organisation, and its credentials are the keys to everything.
Set up the log-archive account before anything else, with S3 Object Lock in compliance mode. Logs an attacker can delete are not evidence. This account should have no human access at all in steady state.
Address planning
IP space is the other thing you cannot retrofit. Overlapping CIDRs between accounts make VPC peering and Transit Gateway routing impossible, and the fix is renumbering a live environment.
Allocate from a single plan, with room to grow:
10.0.0.0/8 — the whole estate
10.0.0.0/12 Production (10.0.0.0 – 10.15.255.255)
10.16.0.0/12 Non-production (10.16.0.0 – 10.31.255.255)
10.32.0.0/12 Shared services (10.32.0.0 – 10.47.255.255)
10.48.0.0/12 Reserved for acquisitions / future regions
Within production, /16 per account:
10.0.0.0/16 payments-prod
10.1.0.0/16 platform-prod
Within an account, /20 per AZ:
10.0.0.0/20 eu-west-1a
10.0.16.0/20 eu-west-1b
10.0.32.0/20 eu-west-1c
Use IPAM to enforce it rather than a spreadsheet that drifts:
resource "aws_vpc_ipam_pool" "production" {
address_family = "ipv4"
ipam_scope_id = aws_vpc_ipam.main.private_default_scope_id
locale = "eu-west-1"
}
resource "aws_vpc_ipam_pool_cidr" "production" {
ipam_pool_id = aws_vpc_ipam_pool.production.id
cidr = "10.0.0.0/12"
}
Reserve the fourth block. Every organisation eventually acquires something with a 10.0.0.0/16 already in use, and having somewhere to put it without renumbering is worth the apparent waste.
Identity: no IAM users
Zero long-lived credentials. Humans authenticate through IAM Identity Center federated to your existing identity provider; workloads use roles.
resource "aws_iam_role" "github_actions" {
name = "github-actions-deploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.github.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
# Scope to one repo and one branch — without this, any repo
# in GitHub can assume the role.
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:acme/platform:ref:refs/heads/main"
}
}
}]
})
}
That StringLike condition is the line people omit, and omitting it means every GitHub repository on the internet can assume your deployment role. Scope the subject claim to the specific repository and ref.
Guardrails with SCPs
Service control policies set the maximum permissions available in an account. They deny; they never grant. Applied at the OU level, they are the backstop for IAM mistakes.
The essential ones:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyLeavingOrganisation",
"Effect": "Deny",
"Action": [
"organizations:LeaveOrganization",
"account:CloseAccount"
],
"Resource": "*"
},
{
"Sid": "DenyDisablingSecurityServices",
"Effect": "Deny",
"Action": [
"guardduty:DeleteDetector",
"guardduty:UpdateDetector",
"securityhub:DisableSecurityHub",
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"config:DeleteConfigurationRecorder"
],
"Resource": "*"
},
{
"Sid": "DenyUnapprovedRegions",
"Effect": "Deny",
"NotAction": [
"iam:*", "organizations:*", "route53:*",
"cloudfront:*", "support:*", "sts:*"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:RequestedRegion": ["eu-west-1", "eu-west-2"]
}
}
}
]
}
Region restriction is the highest-value SCP. It shrinks your monitoring surface from 30-odd regions to two, and cryptomining in an unmonitored region is one of the most common outcomes of a leaked credential. Note the NotAction — global services have endpoints in us-east-1 and will break if you deny it outright.
Test SCPs on a sandbox OU first. An overly broad SCP applied to the root can lock you out of your own organisation, and recovering requires AWS Support. Never attach a policy to the root OU that you have not verified elsewhere.
Networking: centralise egress
Each workload VPC gets private subnets only. Egress goes through a shared inspection VPC via Transit Gateway.
Three benefits, and the third is the one finance notices: one place to inspect and log all egress, one set of IPs to allow-list with partners, and a handful of NAT gateways instead of three per account.
NAT gateway cost is a common surprise — around $32/month each plus $0.045 per GB processed. Three AZs × fifteen accounts is 45 NAT gateways and roughly $1,400/month before a byte moves. Centralised egress with three shared gateways cuts that by an order of magnitude.
Use VPC endpoints for AWS services so that S3 and DynamoDB traffic never touches a NAT gateway at all:
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.eu-west-1.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = aws_route_table.private[*].id
}
Gateway endpoints for S3 and DynamoDB are free. There is no reason not to have them.
Tagging that is actually enforced
Untagged resources cannot be attributed, and cost allocation without attribution is guesswork.
{
"Sid": "RequireCostCentreTag",
"Effect": "Deny",
"Action": ["ec2:RunInstances", "rds:CreateDBInstance"],
"Resource": "*",
"Condition": {
"Null": { "aws:RequestTag/CostCentre": "true" }
}
}
Keep the mandatory set small — four or five tags. Long tag policies get worked around:
CostCentre— who paysOwner— who to contactEnvironment— prod / staging / devDataClassification— public / internal / confidential / restricted
Activate them as cost allocation tags in the billing console. This step is easy to miss and nothing works without it.
Order of implementation
- Organisation + OU structure. Management account clean.
- Log archive account with Object Lock, org-wide CloudTrail landing in it.
- IAM Identity Center federated to your IdP; permission sets per role.
- Baseline SCPs: deny leaving the org, deny disabling security services, restrict regions.
- IPAM with the address plan allocated.
- Network account: Transit Gateway, inspection VPC, centralised egress, Route 53 resolver rules.
- Detective controls: GuardDuty, Security Hub, Config — all org-wide, delegated to security-tooling.
- Account vending — an automated pipeline that creates accounts with the baseline applied.
Step 8 is what makes it sustainable. If creating a compliant account is a manual runbook, people will route around it, and within a year you will have accounts nobody knows about.
0 comments
Sign in to join the discussion.
No comments yet. If something here is wrong or incomplete, say so — corrections are welcome.