Quick take — A reusable Terraform module for google_folder on hashicorp/google ~> 5.0: nested folder hierarchy, inherited IAM bindings, and org policy attachment — wired for production landing zones. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "google" {
project = "my-project"
region = "us-central1"
}
module "folder" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-folder?ref=v1.0.0"
display_name = "..." # Human-readable folder name, unique among siblings (1–30…
parent = "..." # Bare numeric id, or a qualified `folders/123` / `organi…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A GCP Folder (google_folder) is a node in the Cloud Resource Manager hierarchy that sits between the organization and projects. Folders are the primary grouping mechanism in GCP: IAM bindings and Organization Policy constraints applied to a folder are inherited by every sub-folder and project beneath it. This is what makes folders the backbone of any landing zone — you model your business (departments, environments, regulatory boundaries) as a folder tree and let inheritance do the policy distribution for you.
Wrapping google_folder in a reusable module is worth it because a raw folder resource is deceptively simple but error-prone in three ways: the parent reference must be a fully-qualified folders/{id} or organizations/{id} string, IAM bindings are authoritative (a stray google_folder_iam_binding will silently strip every other member from that role), and display_name uniqueness is enforced per-parent at apply time. This module normalizes the parent input, exposes inheritance-safe IAM (additive member grants, not authoritative bindings), optionally attaches a boolean Org Policy, and emits the canonical folders/{id} ID that downstream projects and child folders need.
When to use it
- You are building a landing zone / resource hierarchy and need departments, teams, or environments (prod/nonprod) represented as policy-inheriting nodes.
- You want IAM delegated at the folder level — e.g. grant a platform team
roles/resourcemanager.folderAdminover aSandboxfolder so they can self-serve projects without org-level access. - You need Organization Policy guardrails (e.g.
compute.requireOsLogin,iam.disableServiceAccountKeyCreation) enforced on a whole branch of the tree at once. - You provision projects with Terraform and need a stable parent ID to pass into
google_project.folder_id.
Do not reach for this module for project-level grouping that does not require policy inheritance (use labels), and remember GCP enforces a maximum nesting depth of 10 folders and 300 folders per parent — model accordingly.
Module structure
terraform-module-gcp-folder/
├── versions.tf # provider + Terraform version constraints
├── main.tf # google_folder + IAM members + optional org policy
├── variables.tf # var-driven inputs with validation
└── outputs.tf # folder id/name + parent passthrough
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Normalize parent into the API-required "folders/{id}" or "organizations/{id}".
# Accept a bare numeric id, an org id, or an already-qualified string.
parent = (
can(regex("^(folders|organizations)/", var.parent))
? var.parent
: (var.parent_type == "organization"
? "organizations/${var.parent}"
: "folders/${var.parent}"
)
)
# Fan IAM grants out to one (role, member) pair per binding so each is additive.
iam_member_pairs = flatten([
for role, members in var.iam_members : [
for member in members : {
key = "${role}--${member}"
role = role
member = member
}
]
])
}
resource "google_folder" "this" {
display_name = var.display_name
parent = local.parent
lifecycle {
# display_name is unique per-parent; guard against accidental reparenting
# that would orphan child projects.
prevent_destroy = false
}
}
# Additive, inheritance-safe grants. Using *_member (not *_binding) means this
# module never strips members it does not manage.
resource "google_folder_iam_member" "members" {
for_each = {
for pair in local.iam_member_pairs : pair.key => pair
}
folder = google_folder.this.name
role = each.value.role
member = each.value.member
}
# Optional boolean org-policy guardrail enforced on this folder and everything
# beneath it (e.g. constraints/compute.requireOsLogin).
resource "google_folder_organization_policy" "boolean_constraints" {
for_each = var.boolean_org_policies
folder = google_folder.this.name
constraint = each.key
boolean_policy {
enforced = each.value
}
}
# variables.tf
variable "display_name" {
description = "Human-readable folder name. Must be unique among siblings under the same parent (1-30 chars)."
type = string
validation {
condition = length(var.display_name) >= 1 && length(var.display_name) <= 30
error_message = "display_name must be between 1 and 30 characters."
}
}
variable "parent" {
description = "Parent of the folder. A bare numeric id, or a fully-qualified 'folders/123' / 'organizations/456' string."
type = string
validation {
condition = length(trimspace(var.parent)) > 0
error_message = "parent must not be empty."
}
}
variable "parent_type" {
description = "Used only when 'parent' is a bare numeric id: whether it is a 'folder' or an 'organization'."
type = string
default = "folder"
validation {
condition = contains(["folder", "organization"], var.parent_type)
error_message = "parent_type must be either 'folder' or 'organization'."
}
}
variable "iam_members" {
description = "Map of IAM role => list of members granted additively on this folder (inherited by children). E.g. { \"roles/resourcemanager.folderViewer\" = [\"group:platform@corp.com\"] }."
type = map(list(string))
default = {}
validation {
condition = alltrue([
for role in keys(var.iam_members) : can(regex("^(roles/|organizations/[0-9]+/roles/)", role))
])
error_message = "Each IAM key must be a predefined role ('roles/...') or a custom org role ('organizations/<id>/roles/...')."
}
}
variable "boolean_org_policies" {
description = "Map of boolean Organization Policy constraint => enforced flag, applied to this folder. E.g. { \"constraints/compute.requireOsLogin\" = true }."
type = map(bool)
default = {}
validation {
condition = alltrue([
for c in keys(var.boolean_org_policies) : can(regex("^constraints/", c))
])
error_message = "Each org policy key must start with 'constraints/'."
}
}
# outputs.tf
output "id" {
description = "Fully-qualified folder id in the form 'folders/{folder_id}'."
value = google_folder.this.name
}
output "folder_id" {
description = "Numeric folder id (without the 'folders/' prefix), suitable for google_project.folder_id."
value = trimprefix(google_folder.this.name, "folders/")
}
output "display_name" {
description = "The folder's display name."
value = google_folder.this.display_name
}
output "parent" {
description = "The resolved parent of this folder ('folders/{id}' or 'organizations/{id}')."
value = google_folder.this.parent
}
output "lifecycle_state" {
description = "Lifecycle state of the folder (ACTIVE or DELETE_REQUESTED)."
value = google_folder.this.lifecycle_state
}
How to use it
# A "Production" department folder under the org, with a platform team granted
# admin and an OS Login guardrail enforced on the whole branch.
module "folder" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-folder?ref=v1.0.0"
display_name = "Production"
parent = "123456789012" # org id
parent_type = "organization"
iam_members = {
"roles/resourcemanager.folderAdmin" = ["group:gcp-platform-admins@corp.com"]
"roles/resourcemanager.folderViewer" = ["group:gcp-auditors@corp.com"]
}
boolean_org_policies = {
"constraints/compute.requireOsLogin" = true
"constraints/iam.disableServiceAccountKeyCreation" = true
}
}
# Downstream: place a project directly inside the folder using its numeric id.
resource "google_project" "payments" {
name = "payments-prod"
project_id = "corp-payments-prod"
folder_id = module.folder.folder_id # consumes the module output
billing_account = "012345-67890A-BCDEF0"
}
# Or nest a child folder beneath it by passing the fully-qualified id.
module "folder_payments_team" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-folder?ref=v1.0.0"
display_name = "Payments"
parent = module.folder.id # already "folders/{id}"
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "gcs"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...gcs state bucket/container + key per path...
}
}
2. Module config — live/prod/folder/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-folder?ref=v1.0.0"
}
inputs = {
display_name = "..."
parent = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/folder && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
display_name |
string |
— | Yes | Human-readable folder name, unique among siblings (1–30 chars). |
parent |
string |
— | Yes | Bare numeric id, or a qualified folders/123 / organizations/456 string. |
parent_type |
string |
"folder" |
No | When parent is a bare id, whether it is a folder or organization. |
iam_members |
map(list(string)) |
{} |
No | Role → members granted additively on the folder (inherited by children). |
boolean_org_policies |
map(bool) |
{} |
No | Boolean Org Policy constraint → enforced flag, applied to the folder. |
Outputs
| Name | Description |
|---|---|
id |
Fully-qualified folder id, folders/{folder_id} — pass to a child folder’s parent. |
folder_id |
Numeric folder id (no prefix) — pass to google_project.folder_id. |
display_name |
The folder’s display name. |
parent |
Resolved parent (folders/{id} or organizations/{id}). |
lifecycle_state |
Folder lifecycle state (ACTIVE or DELETE_REQUESTED). |
Enterprise scenario
A regulated fintech runs a two-tier folder hierarchy: top-level Production / NonProduction / Sandbox folders under the org, each with environment-specific Org Policy guardrails, and a per-business-unit child folder beneath each (Payments, Lending, Risk). The platform team instantiates this module once per node in a single root Terraform stack, granting each BU’s lead group roles/resourcemanager.projectCreator on their own folder so teams self-serve projects without ever touching org-level IAM. Because the iam.disableServiceAccountKeyCreation and compute.requireOsLogin constraints are pinned at the Production folder, every project a BU creates inherits the controls automatically — passing the SOC 2 boundary-of-control requirement with no per-project configuration.
Best practices
- Never use authoritative IAM at the folder level casually. This module deliberately uses
google_folder_iam_member(additive) instead ofgoogle_folder_iam_binding; an authoritative binding on a high-level folder can revoke access for every team beneath it in one apply. - Prefer groups over individual users in
iam_members. Folder grants are inherited by all descendants, so auser:grant here can quietly hand someone access to dozens of projects — keep membership in your IdP, not Terraform. - Reference folders by numeric id, never by
display_name. Display names are mutable and only unique per-parent; the numeric id is stable and is whatfolder_id/parentactually resolve against. - Push guardrails as high as possible. Apply
boolean_org_policiesat the broadest folder where the rule holds true (e.g. all ofProduction) rather than repeating it per project — inheritance is the whole point, and it reduces drift. - Mind the limits and depth. GCP caps the hierarchy at 10 levels deep and 300 folders per parent; design a shallow, business-aligned tree rather than mirroring a deep org chart.
- Guard reparenting changes. Moving a folder (changing
parent) silently re-evaluates inherited policy for everything below it; review such diffs carefully and stage them outside of unrelated changes.