Cloud landing zone design: accounts, guardrails, and shared services
For developers and technical leads who need to shape a cloud foundation that won’t collapse under growth, audits, or team sprawl. This guide shows how to structure accounts or subscriptions, apply guardrails as code, and place shared services so teams can ship without getting broad admin access.
TL;DR — A landing zone is the part of your cloud setup that decides where workloads live, what they are allowed to do, and which common services they consume. The most reliable default is: separate accounts/subscriptions by environment and blast radius, apply deny-first guardrails at the org/group level, and centralize only the services that truly benefit from shared operation such as identity, logging, DNS, and network egress. Reading time: ~7 min
What it is and where it sits
A cloud landing zone is the baseline architecture for everything that comes after your first workload. It is not your app stack. It is the layer that decides:
- which account/subscription/project a workload is deployed into
- which identities can deploy or read data there
- which regions, instance families, and public exposure patterns are allowed
- where audit logs, DNS, secrets, CI/CD runners, artifact registries, and network egress live
In practice it replaces the common early-stage anti-pattern of “one cloud account, one admin role, one VPC, and we’ll clean it up later.” That setup works until the first production incident, audit request, acquisition, or team split.
Architecture-wise, the landing zone sits below application infrastructure and above the provider’s raw primitives. Your Terraform/OpenTofu, Pulumi, or provider-native templates target it. Your CI system authenticates into it. Security tooling reads from it. Shared services are either hosted inside dedicated platform accounts/subscriptions or consumed from provider-managed services with org-level controls.
View as text
[Developer] ---- 1. git push ----> [CI/CD] [CI/CD] ---- 2. assume deploy role ----> [Platform Org/Group] [Platform Org/Group] ---- 3. policy check ----> [Workload Account] [Workload Account] ---- 4. deploy app ----> [App Resources] [Workload Account] ---- 5. send logs ----> [Central Logging] [Workload Account] ---- 6. resolve/internal auth ----> [Shared Services] [Security Team] ---- 7. read audit trail ----> [Central Logging]
What talks to it:
- CI/CD pipelines using short-lived credentials
- developers using federated login and role assumption
- org/group-level policy engines
- centralized logging/SIEM
- DNS, PKI, artifact, and secret systems
Where it lives in request/data flow:
- not on the hot path for every HTTP request, except when you centralize network egress, DNS, or identity
- absolutely on the control plane path for every deployment, permission grant, and audit event
The important design choice: optimize for blast-radius containment and operability, not for the fewest accounts. More accounts/subscriptions are usually cheaper than one security incident.
How it actually works
Walk one realistic example: a team deploys a new internal API to production using Terraform from CI.
Step 1: The org/group structure defines placement
A practical default structure looks like this:
- one management/root org or tenant
- one dedicated platform account/subscription for shared services
- one dedicated security/logging account/subscription
- workload accounts/subscriptions separated by environment:
dev,staging,prod - optionally separate by business domain or sensitivity:
payments-prod,analytics-prod
The deployment target is not chosen by naming convention alone. CI gets credentials only for the specific workload account/subscription and environment.
Step 2: Guardrails are applied above the workload
Guardrails should be enforced at the highest level your provider supports: org, management group, folder, or account group. Typical deny rules:
- deny disabling audit logging
- deny creating public IPs except in approved accounts
- deny deployment outside approved regions
- deny broad IAM grants like
*admin roles to human users - require tags/labels such as
owner,data_classification,cost_center
This matters because app teams can accidentally or deliberately bypass controls if the policy only exists inside their repo.
Step 3: Shared services are consumed, not recreated
The workload account uses central services for:
- DNS zones and internal name resolution
- artifact registry or package mirror
- central log sink
- identity federation and role mapping
- egress filtering or NAT if you need fixed outbound IPs
- sometimes secrets or certificate issuance
Do not centralize databases or runtime clusters by default just because they are “shared.” Shared control planes are fine; shared failure domains are not.
Step 4: CI authenticates with short-lived credentials
The CI job exchanges its identity token for a cloud role in the target workload account/subscription. Then it runs plan/apply.
Example command shape:
terraform init -backend-config=env/prod.backend.hcl
terraform plan -var-file=env/prod.tfvars -out=tfplan
terraform apply -auto-approve tfplan
If org-level policy blocks something, the apply fails before drift reaches production. Typical failure shapes look like this:
Error: creating compute instance: policy denied
with module.api_server.cloud_instance.this,
on modules/api/main.tf line 42, in resource "cloud_instance" "this":
42: resource "cloud_instance" "this" {
Reason: region "eu-west-3" is not in allowed set ["eu-west-1", "eu-central-1"]
Policy: org/restrict-regions
Exit code: 1
Or for public exposure:
Error: creating load balancer listener: access denied by organization policy
Reason: public ingress is blocked in account "payments-prod"
Required exception tag missing: "internet_exposed=true"
Exit code: 1
Those are good failures. The landing zone is doing its job.
Step 5: Runtime traffic uses shared services only where justified
After deployment, the API serves traffic inside its own workload boundary. It sends audit and application logs to the central sink. It resolves names via shared DNS. If outbound traffic must come from a fixed IP, it exits through centralized egress.
The subtle point: the landing zone should strongly govern the control plane and selectively centralize data plane pieces. If you centralize too much runtime traffic, you create latency, noisy-neighbor problems, and giant outage domains.
When to use it (and when not to)
You need a landing zone before your cloud estate becomes large, because retrofitting identity boundaries and log retention after the fact is painful.
| Scenario | Recommendation |
|---|---|
| 1-2 engineers, one non-sensitive prototype, no customer data | Keep it simple: separate dev and prod, enable audit logs, use federated login, and stop there for now |
| SaaS product with production customer data | Use separate prod/non-prod accounts or subscriptions immediately; centralize audit logs and identity on day one |
| Multiple teams deploying independently | Add per-team or per-domain workload boundaries; do not let all teams share one prod account |
| Regulated data, customer-managed networking, or audit requirements | Use a formal landing zone with org-level deny policies, central logging, break-glass access, and network standards |
| Heavy shared platform team running common clusters for everyone | Use a landing zone, but keep tenant isolation explicit; shared Kubernetes does not remove the need for account boundaries |
| Short-lived hackathon or throwaway demo | You probably don’t need a full landing zone; use one sandbox account/subscription with a spending cap and no production connectivity |
You probably don’t need the full version if:
- there is no production workload yet
- there is no sensitive data
- one team owns everything and can tolerate rebuilds
- your biggest risk is speed, not compliance or blast radius
Even then, do the cheap parts now: separate prod from non-prod, turn on audit logging, and stop using long-lived access keys.
Trade-offs
Every landing-zone benefit has a bill attached.
- Benefit: smaller blast radius
- Cost: more accounts/subscriptions, more IAM roles, more CI wiring, more provider quotas to track
- Benefit: org-level policy enforcement
- Cost: developers hit confusing deny messages unless you document exceptions and provide paved-road modules
- Benefit: centralized logging and auditability
- Cost: storage and ingestion charges, plus operational work for retention, parsing, and access control
- Benefit: shared DNS, identity, and artifact services
- Cost: platform dependencies; if shared services break, many teams feel it at once
- Benefit: standardized network egress and ingress
- Cost: added latency, NAT/firewall costs, and harder debugging of outbound failures
- Benefit: easier compliance evidence
- Cost: lock-in to provider policy models and org hierarchy concepts; migration later is harder
A common mistake is over-centralization. Centralize governance and observability first. Centralize runtime only when there is a concrete need like fixed egress IPs, private connectivity, or expensive specialist operations.
In practice
Example 1: OpenTofu/Terraform guardrail check in CI
set -euo pipefail
export TF_IN_AUTOMATION=1
tofu init -backend-config=env/prod.backend.hcl
tofu plan \
-var-file=env/prod.tfvars \
-detailed-exitcode \
-out=tfplan
rc=$?
case "$rc" in
0) echo "No changes" ;;
2) echo "Changes detected, running policy check" ;;
1) echo "Plan failed"; exit 1 ;;
esac
conftest test plan.json --policy policy/
tofu apply -auto-approve tfplan
This runs infrastructure changes with -detailed-exitcode, where 0 means no diff, 2 means diff present, and 1 means failure. Gotcha: many CI scripts accidentally treat exit code 2 as an error and fail the pipeline before policy checks or approval gates run.
Example 2: Rego policy to block public ingress in production
package terraform.deny
deny[msg] {
input.resource_changes[_].type == "aws_security_group_rule"
input.resource_changes[_].change.after.type == "ingress"
input.resource_changes[_].change.after.cidr_blocks[_] == "0.0.0.0/0"
input.resource_changes[_].change.after.from_port == 443
input.configuration.root_module.variables.environment.value == "prod"
not input.configuration.root_module.variables.internet_exposed.value
msg := "prod ingress from 0.0.0.0/0 requires internet_exposed=true"
}
This is a portable pattern even if your provider is not AWS: inspect the planned resource graph and fail builds on risky exposure. Gotcha: plan JSON schemas vary by Terraform/OpenTofu version and provider; pin versions in CI or your policy will break on shape changes rather than real risk.
Example 3: Bootstrap account/subscription inventory and drift checks
⚠️ If you script org/account creation or policy attachment against production, a bad loop can lock out deployments or apply deny policies too broadly. Test first in a sandbox org/group and keep one documented break-glass admin path outside the normal federation flow.
#!/usr/bin/env bash
set -euo pipefail
while IFS=, read -r env owner account_id; do
echo "checking $env $account_id"
cloudctl accounts describe --account "$account_id" --output json > "state/$account_id.json"
jq -e '.tags.owner == '"\"$owner\""' "state/$account_id.json" >/dev/null
jq -e '.audit_logging.enabled == true' "state/$account_id.json" >/dev/null
jq -e '.policies | index("restrict-regions")' "state/$account_id.json" >/dev/null
done < accounts.csv
This pattern inventories accounts/subscriptions and asserts baseline controls from the command line. Gotcha: provider CLIs often return partial success with stderr warnings; capture and inspect both stdout and exit status instead of trusting JSON output alone.
A realistic failure shape to expect from inventory scripts:
$ cloudctl accounts describe --account acc-prod-001 --output json
ERROR: AccessDenied: missing permission org:DescribeAccount
exit status 254
That usually means your audit role can read workload resources but not org metadata; fix the role boundary rather than granting broad admin.
Further reading
- AWS Well-Architected Framework — Security Pillar
- Azure Cloud Adoption Framework — Landing zone design areas
- Google Cloud Architecture Framework — Security and governance
- Open Policy Agent documentation — Policy language
- Terraform CLI documentation —
planand machine-readable output
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI