# AWS Landing Zone Foundations

> Account structure, address planning, and guardrails you cannot retrofit

Canonical: https://everettkildare.com/blog/aws-landing-zone-foundations
Author: Everett Kildare
Published: 2026-05-30
Updated: 2026-11-01

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.

> [!IMPORTANT]
> 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.

> [!CAUTION]
> Test SCPs on a sandbox OU first. An overly broad SCP applied to the root can lock every member account out of key services; SCPs never apply to the management account, so recovery means detaching the policy from there. 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.

```mermaid
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 — about $32 a month per gateway at the US East (Ohio) rate of $0.045 an hour, plus $0.045 per GB processed; eu-west-1 and other regions price differently, so check yours. 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.

## Sources

- [Organizing Your AWS Environment Using Multiple Accounts](https://docs.aws.amazon.com/whitepapers/latest/organizing-your-aws-environment/organizing-your-aws-environment.html) — AWS's own guidance that the account is the isolation boundary and that limits are managed per account.
- [Service control policies (SCPs)](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html) — SCPs never grant permissions, do not affect the management account, and should be tested before attaching to the root.
- [AWS: Denies access to AWS based on the requested Region](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_examples_aws_deny-requested-region.html) — the `aws:RequestedRegion` pattern and why global services in us-east-1 need a `NotAction` exemption.
- [Configuring OpenID Connect in Amazon Web Services](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws) — the `aud` and `sub` trust-policy conditions for GitHub Actions roles.
- [Locking objects with Object Lock](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html) — compliance mode prevents deletion by any user, including the root user.
- [Amazon VPC Pricing](https://aws.amazon.com/vpc/pricing/) — NAT gateway hourly and per-GB charges ($0.045 each in the US East example) and free gateway endpoints.
