The resource hierarchy is the single most important design decision you make on Google Cloud, because IAM bindings and Org Policies flow down it by inheritance. Get the topology right and guardrails become a few constraints set high in the tree; get it wrong and you spend the next two years stapling exceptions onto individual projects. This guide walks the whole thing end to end: mapping business units to a folder/project layout, bootstrapping the org node and a Terraform seed project, choosing a folder strategy, applying inherited Org Policy constraints, and validating the result.
1. Map business units to topology before you create anything
Resist the urge to open the console. The hierarchy on GCP is Organization -> Folders (nested up to 10 deep) -> Projects -> Resources, and both IAM and Org Policy inherit at every level. That means the folder structure is your governance boundary. Sketch it on paper first.
Two questions drive the design:
- Where do policy boundaries fall? A folder is the unit at which you grant a team autonomy and apply distinct guardrails. If platform and payments need different allowed regions or different SA-key rules, they belong in different folders.
- Where do billing and cost attribution fall? Projects are the atomic unit of billing. One workload + one environment = one project is the rule of thumb. Avoid the “one giant project” anti-pattern; it destroys cost attribution and blast-radius isolation.
A minimal target topology for a mid-size org:
Organization (example.com)
├── fldr-bootstrap # Terraform state, CI/CD service accounts
├── fldr-common # shared services: logging, DNS, networking hub
├── fldr-platform
│ ├── prj-platform-prod
│ └── prj-platform-nonprod
└── fldr-business-units
├── fldr-payments
│ ├── prj-payments-prod
│ └── prj-payments-dev
└── fldr-data
├── prj-data-prod
└── prj-data-dev
Callout: keep a dedicated
fldr-bootstrap(orfldr-common) for the seed project and CI service accounts. It is the one place you grant elevated, hierarchy-wide permissions, and you want it isolated and heavily audited.
2. Bootstrap the org node, billing, and a seed project for Terraform state
You need a human with roles/resourcemanager.organizationAdmin and roles/billing.admin at the org level for the initial bootstrap. The seed project is special: it holds Terraform state and a service account that will later create everything else, so it is created by hand once.
Find your org and billing IDs:
# Organization ID (numeric)
gcloud organizations list
# Billing accounts you can link
gcloud billing accounts list
Create the seed project and link billing:
export ORG_ID="123456789012"
export BILLING_ACCOUNT="0X0X0X-0X0X0X-0X0X0X"
export SEED_PROJECT="acme-bootstrap-seed"
gcloud projects create "${SEED_PROJECT}" \
--organization="${ORG_ID}"
gcloud billing projects link "${SEED_PROJECT}" \
--billing-account="${BILLING_ACCOUNT}"
Enable the APIs the foundation needs, then create the remote state bucket with versioning so Terraform state history is recoverable:
gcloud config set project "${SEED_PROJECT}"
gcloud services enable \
cloudresourcemanager.googleapis.com \
cloudbilling.googleapis.com \
iam.googleapis.com \
serviceusage.googleapis.com \
orgpolicy.googleapis.com \
storage.googleapis.com
# Remote state bucket, uniform access + versioning
gcloud storage buckets create "gs://${SEED_PROJECT}-tfstate" \
--location=us \
--uniform-bucket-level-access \
--public-access-prevention
gcloud storage buckets update "gs://${SEED_PROJECT}-tfstate" --versioning
Create the Terraform service account and grant it the org-level roles it needs to manage the hierarchy. Prefer Workload Identity Federation from your CI system over downloading a JSON key.
gcloud iam service-accounts create tf-foundation \
--project="${SEED_PROJECT}" \
--display-name="Terraform Foundation SA"
export TF_SA="tf-foundation@${SEED_PROJECT}.iam.gserviceaccount.com"
# Roles needed to create folders, projects, and set org policy
for ROLE in \
roles/resourcemanager.folderAdmin \
roles/resourcemanager.projectCreator \
roles/orgpolicy.policyAdmin \
roles/billing.user ; do
gcloud organizations add-iam-policy-binding "${ORG_ID}" \
--member="serviceAccount:${TF_SA}" \
--role="${ROLE}"
done
roles/billing.useron the org (or on the billing account) lets the SA link new projects to billing.roles/orgpolicy.policyAdminis what lets it set the constraints in step 4. Grant these at the org node so they inherit everywhere new projects land.
3. Folder strategy: environment-based vs domain-based vs hybrid
This is where teams argue. There is no universally correct answer, only trade-offs against how your org actually grants access and applies policy.
| Strategy | Layout | Best when | Trade-off |
|---|---|---|---|
| Environment-first | fldr-prod, fldr-nonprod under the org |
Policy differs mostly by environment (prod is locked down, dev is permissive) | Team autonomy is awkward; a team’s prod and dev live in different subtrees |
| Domain/team-first | fldr-payments, fldr-data, each containing prod/dev projects |
You want to delegate a whole folder to a team and let them self-serve | Environment-wide policy must be set per team folder or via tags |
| Hybrid | Team folders at the top, environment encoded in the project (and a tag), prod/nonprod folders only where needed | Most mid-to-large orgs | More moving parts; relies on disciplined naming + tags |
My default recommendation is domain/team-first with environment encoded as a tag (see step 6). It maps cleanly to org structure, makes folder-level IAM delegation natural, and lets you still target “all production” via tag-bound policies rather than forcing prod into its own subtree.
Watch the 10-level nesting limit and the per-folder/per-org quotas. Going more than 3-4 levels deep almost always signals you are modelling org-chart politics rather than policy boundaries.
4. Apply inherited Org Policy constraints
Org Policies are restrictions you set on a resource node (org, folder, or project) that constrain how resources below it can be configured. They are distinct from IAM: IAM answers “who can act”, Org Policy answers “what configurations are allowed at all”. Set them high so they inherit.
Three guardrails every landing zone should have:
Restrict resource locations (gcp.resourceLocations) — a list constraint that limits which regions/locations resources can be created in.
cat > locations-policy.yaml <<'EOF'
name: organizations/123456789012/policies/gcp.resourceLocations
spec:
rules:
- values:
allowedValues:
- in:us-locations
- in:europe-west1-locations
EOF
gcloud org-policies set-policy locations-policy.yaml
Disable service account key creation (iam.disableServiceAccountKeyCreation) — a boolean constraint. Long-lived SA keys are the most common credential-leak vector; block them and push teams to Workload Identity.
gcloud org-policies enable-enforce \
iam.disableServiceAccountKeyCreation \
--organization="${ORG_ID}"
Enforce CMEK and restrict the KMS projects that can supply keys. Two constraints work together here: gcp.restrictNonCmekServices denies resource creation in listed services unless CMEK is supplied, and gcp.restrictCmekCryptoKeyProjects limits which projects’ keys are acceptable.
cat > cmek-policy.yaml <<'EOF'
name: organizations/123456789012/policies/gcp.restrictNonCmekServices
spec:
rules:
- values:
deniedValues:
- storage.googleapis.com
- bigquery.googleapis.com
EOF
gcloud org-policies set-policy cmek-policy.yaml
Roll restrictive policies out to a non-prod folder first.
gcp.resourceLocationsin particular will block legitimate global resources if you forget to allow the relevantin:location groups (for example,in:us-locationscovers multi-regionus).
5. Boolean vs list constraints, and how inheritance actually resolves
Getting inheritance wrong is the number-one source of “why is my policy not applying” tickets. There are two constraint types and they merge differently.
Boolean constraints are simply enforced or not. A child policy can override the parent by setting its own enforce value. Example: enforce disableServiceAccountKeyCreation at the org, then explicitly not enforce it on a single sandbox project.
# project-level override that turns the boolean OFF for a sandbox
name: projects/acme-sandbox-xyz/policies/iam.disableServiceAccountKeyCreation
spec:
rules:
- enforce: false
List constraints are more subtle. By default a child policy that sets rules replaces the inherited values entirely — it does not union with them. To add to what a parent allows, you set inheritFromParent: true in the child spec so the child’s values merge with the inherited ones.
# child folder ADDS asia-southeast1 on top of whatever the org already allows
name: folders/444555666/policies/gcp.resourceLocations
spec:
inheritFromParent: true
rules:
- values:
allowedValues:
- in:asia-southeast1-locations
Key rules to internalize:
- A
denyanywhere in the hierarchy wins; you cannot un-deny a value lower down by allowing it. - Without
inheritFromParent: true, a child list policy starts from scratch and ignores the parent’s list. reset: truein a spec drops the locally set policy and falls back to pure inheritance — useful for clearing a one-off exception.
Inspect the effective (post-merge) policy on any node rather than guessing:
gcloud org-policies describe gcp.resourceLocations \
--project="acme-payments-prod" \
--effective
6. Tags and labels for cost attribution across the hierarchy
Two different mechanisms, frequently confused:
- Labels are key/value pairs on individual resources, surfaced in billing export for cost attribution. They do not participate in IAM or Org Policy. Use them for
cost-center,team,app. - Tags (resource-manager Tags) are hierarchical key/value pairs defined at org/folder level, inherited by descendants, and bindable to IAM conditions and Org Policy. Use them to target policy at, say, “everything tagged environment=production” regardless of folder.
Create a tag and bind it so a policy can key off it:
# Define a tag key + value at the org
gcloud resource-manager tags keys create environment \
--parent="organizations/${ORG_ID}"
gcloud resource-manager tags values create production \
--parent="${ORG_ID}/environment"
# Bind the tag value to a project (inherited by its resources)
gcloud resource-manager tags bindings create \
--tag-value="${ORG_ID}/environment/production" \
--parent="//cloudresourcemanager.googleapis.com/projects/acme-payments-prod"
For cost attribution, enforce a labelling convention on every project and rely on BigQuery billing export. A useful sanity query once export is flowing:
SELECT
labels.value AS cost_center,
ROUND(SUM(cost), 2) AS total_cost
FROM `acme-billing.billing_export.gcp_billing_export_v1_XXXXXX`
LEFT JOIN UNNEST(labels) AS labels
WHERE labels.key = 'cost-center'
AND _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY cost_center
ORDER BY total_cost DESC
7. Codify the hierarchy with the Cloud Foundation Toolkit modules
Click-ops does not survive an audit. The terraform-google-modules project ships maintained modules for exactly this. Wire your seed bucket as the backend, then build folders and projects from modules.
terraform {
backend "gcs" {
bucket = "acme-bootstrap-seed-tfstate"
prefix = "foundation/hierarchy"
}
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}
module "fldr_payments" {
source = "terraform-google-modules/folders/google"
version = "~> 5.0"
parent = "organizations/${var.org_id}"
names = ["fldr-payments"]
}
module "prj_payments_prod" {
source = "terraform-google-modules/project-factory/google"
version = "~> 18.0"
name = "acme-payments-prod"
org_id = var.org_id
billing_account = var.billing_account
folder_id = module.fldr_payments.ids["fldr-payments"]
activate_apis = [
"compute.googleapis.com",
"logging.googleapis.com",
]
labels = {
cost-center = "payments"
env = "prod"
}
}
Manage Org Policies as code too. The org-policy module wraps both boolean and list constraints so they live in version control beside the hierarchy:
module "policy_disable_sa_keys" {
source = "terraform-google-modules/org-policy/google"
version = "~> 6.0"
organization_id = var.org_id
constraint = "constraints/iam.disableServiceAccountKeyCreation"
policy_type = "boolean"
enforce = true
}
module "policy_locations" {
source = "terraform-google-modules/org-policy/google"
version = "~> 6.0"
organization_id = var.org_id
constraint = "constraints/gcp.resourceLocations"
policy_type = "list"
allow = ["in:us-locations", "in:europe-west1-locations"]
allow_list_length = 2
}
Pin module and provider versions. The Cloud Foundation Toolkit modules change behaviour across majors, and the underlying
google_org_policy_policyresource (v2 Org Policy API) differs meaningfully from the legacygoogle_organization_policy. Standardize on the v2 API for new builds.
Enterprise scenario
A fintech platform team I worked with enforced gcp.resourceLocations at the org node, allow-listing in:eu-locations only, to satisfy a data-residency commitment. Weeks later a downstream team filed a ticket: their new project could not enable Cloud Logging’s _Default bucket, and provisioning a global external HTTPS load balancer failed with a policy violation. The constraint was doing exactly what it was told — but in:eu-locations does not cover resources Google models as global, and several foundational services (log buckets created in global, certain load-balancer components, Cloud DNS) are global by design.
The instinct was to relax the policy to in:eu-locations plus a blanket allow, which would have quietly defeated the residency guarantee. The correct fix is to add the dedicated global value group, which permits global-scoped resources without opening up any regional location:
name: organizations/123456789012/policies/gcp.resourceLocations
spec:
rules:
- values:
allowedValues:
- in:eu-locations
- in:global-locations
We validated blast radius before rollout by shipping it as a dryRunSpec first and reading the audit logs for a week — zero new violations, confirming no regional drift crept in. The deeper lesson: location constraints reason over Google’s value groups, not raw region strings, and global is a first-class group you almost always have to allow explicitly. We codified the final policy in the CFT org-policy module so the in:global-locations entry is reviewed in PR rather than hot-patched in the console under incident pressure.
Verify
Confirm the hierarchy and that guardrails resolve as intended.
# Hierarchy renders as expected
gcloud projects list --filter="parent.id=${FOLDER_ID}"
gcloud resource-manager folders list --organization="${ORG_ID}"
# Effective org policy at a leaf project (post-inheritance merge)
gcloud org-policies describe gcp.resourceLocations \
--project="acme-payments-prod" --effective
# Prove the SA-key guardrail actually blocks creation
gcloud iam service-accounts keys create /tmp/test-key.json \
--iam-account="some-sa@acme-payments-prod.iam.gserviceaccount.com"
# Expected: FAILED_PRECONDITION / policy violation, no key written
Use Policy Troubleshooter to explain why a principal can or cannot do something — it walks the inherited IAM bindings for you:
gcloud policy-troubleshoot iam \
//cloudresourcemanager.googleapis.com/projects/acme-payments-prod \
--principal-email="dev@example.com" \
--permission="resourcemanager.projects.get"
Before enforcing a new restriction org-wide, set it as a dry-run policy (dryRunSpec). Violations are logged to Cloud Audit Logs without blocking anything, so you can measure blast radius first.
gcloud org-policies set-policy locations-dryrun.yaml
# where the YAML uses dryRunSpec instead of spec
gcloud logging read \
'protoPayload.status.message:"Org Policy"' \
--project="acme-payments-prod" --limit=20
Checklist
Pitfalls and next steps
The mistakes that hurt most: setting list policies on a child without inheritFromParent: true and silently wiping the parent’s allowlist; enabling gcp.resourceLocations without allowing global/multi-region location groups and breaking legitimate resources; and downloading SA keys “just for the pipeline” after you spent effort disabling them — use Workload Identity Federation instead. Also remember a single project cannot move between organizations, and folder deletion requires the subtree be empty, so plan moves before they calcify.
Next, layer on a VPC Service Controls perimeter around your sensitive data projects, wire Security Command Center to the org node for posture findings, and add a terraform plan gate in CI that fails on any drift from the codified hierarchy. At that point the hierarchy stops being a diagram and becomes an enforced, auditable contract.