IaC GCP

Terraform Module: GCP Folder — Codified Resource Hierarchy with Inherited IAM

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

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 configlive/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 configlive/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

TerraformGCPFolderModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading