Cloud Landing Zone Design

Design secure, scalable multi-account cloud foundations. Covers account structure, organizational units, service control policies, network topology, identity federation, and the patterns that make cloud environments governable from day one.

Cloud Landing Zone Design establishes a repeatable, secure, and governed foundation for deploying and operating cloud workloads across teams, environments, and regions. It matters when an organisation transitions from single projects or siloed teams to a shared, multi-team cloud platform, especially when onboarding new teams, standardising infrastructure, or scaling across multiple accounts or regions. A landing zone is not a single configuration, but a living system of policies, templates, tooling, and operational processes that ensure consistency, compliance, and velocity.

Foundational Components

A landing zone begins with a set of core infrastructure assets, consistently provisioned and managed through declarative configuration. These assets are typically defined using Infrastructure-as-Code (IaC) and deployed via CI/CD pipelines. The foundation includes:

Core Infrastructure Templates

The heart of the landing zone is a set of reusable templates. These are typically written in Terraform, CloudFormation, or Pulumi, and stored in a version-controlled repository. Each template is a self-contained module with clear inputs and outputs.


resource "aws_vpc" "main" {
  cidr_block = var.cidr_block
  enable_dns_hostnames = true
  enable_dns_support = true

  tags = {
    Name = "${var.environment}-${var.project_name}-vpc"
    Owner = "platform-engineering"
    Environment = var.environment
  }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidr
  availability_zone       = var.availability_zone
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.environment}-${var.project_name}-public-subnet"
    Role = "public"
  }
}

The main.tf file above defines a VPC with public subnets. It uses variables (var.environment, var.project_name) that are passed in at runtime. These variables are declared in variables.tf:

# terraform/variables.tf
variable "environment" {
  type = string
  description = "The environment: dev, staging, production"
}

variable "project_name" {
  type = string
  description = "Name of the project deploying into this VPC"
}

variable "cidr_block" {
  type = string
  default = "10.0.0.0/16"
}

When a new team deploys a new service, they instantiate this module with a terraform.tfvars file:

# terraform/environments/dev.tfvars
environment       = "dev"
project_name      = "customer-onboarding"
cidr_block        = "10.1.0.0/16"
public_subnet_cidr = "10.1.1.0/24"
availability_zone = "us-east-1a"

The landing zone enforces the use of this structure: every team must define their environment-specific variables in a tfvars file and apply the configuration via a CI pipeline.

Policy as Code

Governance is enforced through policy as code. Policies are defined using tools like Open Policy Agent (OPA), AWS Config Rules, or Azure Policy. These policies are evaluated at infrastructure deployment time and continuously as the environment evolves.

An OPA policy file (rego) checks that all EC2 instances have at least one tag, are in a public subnet, and use an approved AMI:

# policies/iam/ensure-ami-is-approved.rego
package cloud.policy.ami

import data.aws.ami

# Ensure AMI is in the approved list
is_approved_ami(ami) {
  amii = data.aws.ami.approved_ami_ids
  amii[ami]
}

# Validate EC2 instance configuration
main = {
  "results": [
    {
      "resource": resource,
      "status": status,
      "message": message
    }
  ]
}

# Apply to every EC2 instance
resource = {
  "type": "aws.ec2.instance",
  "id": instance.id,
  "name": instance.tags.Name,
  "ami": instance.image_id
}

status = if all_tagged and is_public_subnet and is_approved_ami(instance.image_id) then "pass" else "fail"

message = if all_tagged and is_public_subnet and is_approved_ami(instance.image_id) then
  "Instance meets AMI, tagging, and subnet requirements."
else
  if not all_tagged then
    "Instance missing required tags."
  else if not is_public_subnet then
    "Instance not in a public subnet."
  else
    "Instance uses unapproved AMI."

This policy is applied during CI using opa CLI:

opa eval --format pretty --data policies/iam/ensure-ami-is-approved.rego \
  --input terraform/outputs.json \
  'main.results'

The outputs.json file contains the state of all deployed resources. If any evaluation fails, the CI pipeline fails, blocking deployment.

Cross-Environment Configuration

A landing zone must support multiple environments, each with its own configuration. This is managed through environment-specific configuration files and a shared configuration hierarchy.

The typical layout is:

config/
├── environments/
│   ├── dev/
│   │   ├── application.json
│   │   ├── database.yml
│   │   └── secrets.yaml
│   ├── staging/
│   │   ├── application.json
│   │   └── secrets.yaml
│   └── production/
│       ├── application.json
│       └── secrets.yaml
└── shared/
    ├── network.yaml
    └── security.yaml

Each environment inherits settings from the shared directory. A service's configuration is merged from top to bottom: shared → environment → environment-specific.

A deployment script uses jq to merge configuration:

#!/bin/bash
# deploy.sh

ENVIRONMENT=$1
CONFIG_DIR="./config/environments/$ENVIRONMENT"

# Merge shared and environment-specific config
jq -s '
  [.[0] as $shared | .[1] as $env | $shared * $env]
' \
  config/shared/*.yaml \
  $CONFIG_DIR/*.yaml \
  > merged-config.yaml

# Deploy using Helm
helm upgrade --install \
  --values merged-config.yaml \
  --set environment=$ENVIRONMENT \
  myapp ./charts/myapp

The jq script merges YAML files from the shared and environment directories using deep merge. If a key exists in both, the environment-specific value overrides the shared one. This prevents duplication and ensures consistency.

Identity and Access Management

Access to cloud resources is governed by a central IAM model. All services use a standard set of roles and policies, with cross-account access for shared services.

A new team onboarding into the landing zone follows a standard process:

  1. Create a service account in the identity provider (IdP) using SAML or OIDC.
  2. Request a new role in the platform account via a ticket or pull request.
  3. The platform team creates an IAM role in the root account with a trust policy allowing the service account to assume it.

The role configuration includes:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    },
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::123456789012:role/customer-onboarding-dev"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

Each application uses a standard trust-policy.json file that is versioned in the landing zone repository. When a new environment is created, the team applies the role using aws sts assume-role:

aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/customer-onboarding-prod \
  --role-session-name "prod-deployment" \
  --output json \
  > session.json

The output includes AccessKeyId, SecretAccessKey, and SessionToken. These are used to configure the application environment.

Shared Services and APIs

The landing zone includes a set of shared services: a message broker, a centralized configuration store, a secrets manager, and a service discovery system. These services are deployed once and consumed by all teams.

A common pattern is to use AWS Systems Manager Parameter Store or Azure App Configuration for shared configuration. A service reads its configuration using the aws ssm get-parameters command:

aws ssm get-parameters \
  --names \
    /myapp/dev/db-host \
    /myapp/dev/db-port \
    /myapp/dev/api-key \
  --query 'Parameters[*].{Name:Name,Value:Value}' \
  --output json

The response is:

[
  {
    "Name": "/myapp/dev/db-host",
    "Value": "db-dev.example.com"
  },
  {
    "Name": "/myapp/dev/db-port",
    "Value": "5432"
  },
  {
    "Name": "/myapp/dev/api-key",
    "Value": "sk_test_123abc"
  }
]

This configuration is consumed by an application at startup. The application can also poll for updates using ssm get-parameters-by-path in a background task.

Operational Observability

Observability is baked into the landing zone. All services emit logs in a standard format (e.g., JSON with timestamp, level, service, trace_id) and send them to a central log aggregation system. The landing zone defines a standard logging.conf for each service:

# logging.conf
[loggers]
default = "info"
myapp = "debug"

[formatters]
json = { format = "%(timestamp)s %(level)s %(service)s %(message)s %(trace_id)s" }

[handlers]
console = {
  type = "stream",
  formatter = "json",
  level = "info"
}

[handlers]
s3 = {
  type = "file",
  filename = "/var/log/myapp.log",
  formatter = "json",
  level = "debug"
}

[loggers.myapp]
handlers = ["console", "s3"]
level = "debug"

This file is used by python logging or log4j to configure logging. The landing zone also defines a standard set of metrics, exposed via Prometheus or CloudWatch. A service exposes metrics via a /metrics endpoint:

{
  "http_requests_total": {
    "count": 12345,
    "duration_seconds": {
      "sum": 45.6,
      "count": 12345,
      "p95": 0.032
    }
  },
  "database_connections": {
    "current": 23,
    "max": 50
  }
}

The landing zone monitors these metrics with a standard set of alerts. A prometheus.yml file defines scraping jobs:

# prometheus.yml
scrape_configs:
  - job_name: 'myapp'
    metrics_path: '/metrics'
    static_configs:
      - targets: ['myapp-prod.example.com:8080']
    relabel_configs:
      - source_labels: [__meta_kubernetes_pod_name]
        target_label: pod_name
      - source_labels: [__meta_kubernetes_namespace]
        target_label: namespace

Failure Modes and Sharp Edges

Several failure modes are common in landing zone deployments:

  {
    "required": ["Name", "Environment", "Owner"],
    "recommended": ["Team", "Project", "CostCenter"],
    "pattern": {
      "Name": "^[A-Za-z0-9][A-Za-z0-9\\-]+[A-Za-z0-9]$"
    }
  }

A script validates tags using jq and aws ec2 describe-instances:

  aws ec2 describe-instances --query 'Reservations[*].Instances[*].[InstanceId,Tags]' --output json \
    | jq -r '
      .[] | 
      .[] | 
      .[] | 
      select(.Tags | any(.Key == "Name")) | 
      select(.Tags | any(.Key == "Environment")) |
      select(.Tags | any(.Key == "Owner"))
    '
  {
    "Code": "AccessDenied",
    "Message": "User: arn:aws:iam::123456789012:user/myapp-dev is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::123456789012:role/myapp-prod"
  }

The fix is to ensure the trust policy includes the correct Principal and Action.

Summary

A landing zone is not a configuration file, but a system of interlocking components: templates, policies, identity, shared services, and observability. It enables teams to onboard quickly, deploy consistently, and operate reliably. The key to success is enforcing a small set of repeatable patterns across all teams, with clear tooling and failure feedback. The landing zone becomes the source of truth for cloud operations.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.