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.

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

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

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

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

JSON
{
  "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.

graph TB subgraph "Workload accounts" A[payments-prod VPC] B[platform-prod VPC] end TGW[Transit Gateway] subgraph "network-prod" INSP[Inspection VPC<br/>Network Firewall] NAT[NAT Gateways] end A --> TGW B --> TGW TGW --> INSP INSP --> NAT NAT --> INET[Internet]

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:

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

JSON
{
  "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 pays
  • Owner — who to contact
  • Environment — prod / staging / dev
  • DataClassification — 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

  1. Organisation + OU structure. Management account clean.
  2. Log archive account with Object Lock, org-wide CloudTrail landing in it.
  3. IAM Identity Center federated to your IdP; permission sets per role.
  4. Baseline SCPs: deny leaving the org, deny disabling security services, restrict regions.
  5. IPAM with the address plan allocated.
  6. Network account: Transit Gateway, inspection VPC, centralised egress, Route 53 resolver rules.
  7. Detective controls: GuardDuty, Security Hub, Config — all org-wide, delegated to security-tooling.
  8. 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.