IaC GCP

Terraform Module: GCP Project — Governed Project Vending with Billing and Baseline APIs

Quick take — A reusable hashicorp/google ~> 5.0 module that vends GCP projects under a folder with billing attached, baseline APIs enabled, default network removal, and least-privilege wiring. 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 "project" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-project?ref=v1.0.0"

  name            = "..."  # Human-readable display name for the project (4-30 chars…
  billing_account = "..."  # Billing account ID in the form `XXXXXX-XXXXXX-XXXXXX`.
  environment     = "..."  # Environment label: one of `dev`, `stg`, `prd`, `sandbox…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

In GCP, the project is the fundamental unit of resource isolation, billing, IAM, and quota. Every compute instance, bucket, BigQuery dataset, and service account lives inside exactly one project, and almost every API call is scoped to a project ID. Because projects are the boundary everything else hangs off, hand-creating them in the console produces snowflakes: inconsistent IDs, forgotten billing links, the legacy default VPC left running, and no idea which APIs are turned on.

This module wraps the google_project resource (plus the three things a project almost never ships usefully without — billing association, baseline service/API enablement, and removal of the auto-created default network) into a single, opinionated unit you can call once per workload. It enforces a naming and ID convention, attaches the project to a folder in your resource hierarchy, links a billing account, turns on only the APIs you ask for via google_project_service, and optionally deletes the default VPC so nothing accidentally gets a public IP on day one. The result is a “project vending machine”: every team gets a consistent, audit-friendly project instead of a bespoke one.

When to use it

Skip it for throwaway sandbox projects you’ll delete in an hour, or when an existing project already exists and you only need to manage resources inside it.

Module structure

terraform-module-gcp-project/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_project + billing, services, default-network removal
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # project_id, number, name + key attributes

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.5"
    }
  }
}

main.tf

locals {
  # GCP project IDs must be 6-30 chars, lowercase, globally unique and immutable.
  # We append a short random suffix to a stable name to avoid collisions while
  # keeping the prefix human-readable. Suffix is opt-in via var.random_project_id.
  base_id     = lower(var.project_id != null ? var.project_id : var.name)
  project_id  = var.random_project_id ? format("%s-%s", local.base_id, random_id.suffix[0].hex) : local.base_id

  # Always tag projects with managed-by + environment for cost allocation and audit.
  default_labels = {
    managed-by  = "terraform"
    environment = var.environment
  }
  labels = merge(local.default_labels, var.labels)
}

resource "random_id" "suffix" {
  count       = var.random_project_id ? 1 : 0
  byte_length = 2 # 4 hex chars, e.g. "a1b2"
}

resource "google_project" "this" {
  name            = var.name
  project_id      = local.project_id
  folder_id       = var.folder_id
  org_id          = var.folder_id == null ? var.org_id : null
  billing_account = var.billing_account
  labels          = local.labels

  # Keep Terraform from silently inheriting the org default network and policies.
  auto_create_network = false

  # When true, `terraform destroy` will not delete the project if it still has
  # liens or non-default resources. Set false only for ephemeral projects.
  deletion_policy = var.deletion_policy
}

# Enable exactly the APIs the workload needs. Leaving them off keeps attack
# surface and quota footprint minimal; toggling here is fully auditable.
resource "google_project_service" "enabled" {
  for_each = toset(var.activate_apis)

  project = google_project.this.project_id
  service = each.value

  # Don't disable an API on destroy if other config still depends on it.
  disable_dependent_services = var.disable_dependent_services
  disable_on_destroy         = var.disable_services_on_destroy
}

# New GCP projects auto-create a "default" VPC with permissive firewall rules.
# In a landing zone you almost always want Shared VPC instead, so remove it.
resource "google_compute_network" "default" {
  count = var.remove_default_network ? 1 : 0

  project                 = google_project.this.project_id
  name                    = "default"
  auto_create_subnetworks = false
  description             = "Imported placeholder so Terraform can delete the auto-created default network."

  depends_on = [google_project_service.enabled]

  lifecycle {
    # We only manage this to delete it; never recreate if drift appears.
    prevent_destroy = false
  }
}

variables.tf

variable "name" {
  description = "Human-readable display name for the project (4-30 chars)."
  type        = string

  validation {
    condition     = length(var.name) >= 4 && length(var.name) <= 30
    error_message = "Project display name must be between 4 and 30 characters."
  }
}

variable "project_id" {
  description = "Explicit project ID. If null, the lowercased name (plus optional random suffix) is used."
  type        = string
  default     = null

  validation {
    condition     = var.project_id == null || can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.project_id))
    error_message = "project_id must be 6-30 chars, start with a letter, and contain only lowercase letters, digits, and hyphens."
  }
}

variable "random_project_id" {
  description = "Append a random 4-hex-char suffix to the project ID to guarantee global uniqueness."
  type        = bool
  default     = true
}

variable "folder_id" {
  description = "Parent folder ID (e.g. 'folders/123456789'). Mutually exclusive with org_id; takes precedence if both set."
  type        = string
  default     = null
}

variable "org_id" {
  description = "Parent organization ID (numeric, e.g. '987654321'). Used only when folder_id is null."
  type        = string
  default     = null

  validation {
    condition     = var.org_id == null || can(regex("^[0-9]+$", var.org_id))
    error_message = "org_id must be a numeric organization ID without the 'organizations/' prefix."
  }
}

variable "billing_account" {
  description = "Billing account ID to associate with the project (format 'XXXXXX-XXXXXX-XXXXXX')."
  type        = string

  validation {
    condition     = can(regex("^[A-F0-9]{6}-[A-F0-9]{6}-[A-F0-9]{6}$", var.billing_account))
    error_message = "billing_account must be in the form 'XXXXXX-XXXXXX-XXXXXX' (uppercase hex)."
  }
}

variable "environment" {
  description = "Environment label applied to the project (dev, stg, prd, sandbox)."
  type        = string

  validation {
    condition     = contains(["dev", "stg", "prd", "sandbox"], var.environment)
    error_message = "environment must be one of: dev, stg, prd, sandbox."
  }
}

variable "labels" {
  description = "Additional labels merged onto the managed-by/environment defaults."
  type        = map(string)
  default     = {}
}

variable "activate_apis" {
  description = "List of Google service APIs to enable on the project (e.g. 'compute.googleapis.com')."
  type        = list(string)
  default = [
    "compute.googleapis.com",
    "iam.googleapis.com",
    "logging.googleapis.com",
    "monitoring.googleapis.com",
  ]
}

variable "remove_default_network" {
  description = "Delete the auto-created 'default' VPC so only explicit/Shared VPC networking is allowed."
  type        = bool
  default     = true
}

variable "disable_services_on_destroy" {
  description = "Whether to disable enabled APIs when the project or module is destroyed."
  type        = bool
  default     = false
}

variable "disable_dependent_services" {
  description = "Whether disabling an API also disables services that depend on it."
  type        = bool
  default     = false
}

variable "deletion_policy" {
  description = "Project deletion behaviour: 'PREVENT', 'ABANDON', or 'DELETE'."
  type        = string
  default     = "PREVENT"

  validation {
    condition     = contains(["PREVENT", "ABANDON", "DELETE"], var.deletion_policy)
    error_message = "deletion_policy must be one of: PREVENT, ABANDON, DELETE."
  }
}

outputs.tf

output "project_id" {
  description = "The globally unique, immutable project ID (use this for almost every downstream provider/resource)."
  value       = google_project.this.project_id
}

output "project_number" {
  description = "The auto-generated numeric project number (used by some APIs and IAM bindings, e.g. service agents)."
  value       = google_project.this.number
}

output "name" {
  description = "The human-readable display name of the project."
  value       = google_project.this.name
}

output "folder_id" {
  description = "The parent folder ID the project was created under (null if created directly under the org)."
  value       = google_project.this.folder_id
}

output "billing_account" {
  description = "The billing account associated with the project."
  value       = google_project.this.billing_account
}

output "enabled_apis" {
  description = "The set of service APIs enabled on the project by this module."
  value       = sort([for s in google_project_service.enabled : s.service])
}

How to use it

module "project" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-project?ref=v1.0.0"

  name            = "payments-api-prd"
  project_id      = "payments-api-prd"
  folder_id       = "folders/482910374625"
  billing_account = "01ABCD-2345EF-67890A"
  environment     = "prd"

  # Globally unique ID without a random suffix because we want a stable, known ID.
  random_project_id = false

  activate_apis = [
    "compute.googleapis.com",
    "container.googleapis.com",
    "sqladmin.googleapis.com",
    "secretmanager.googleapis.com",
    "logging.googleapis.com",
    "monitoring.googleapis.com",
  ]

  labels = {
    cost-center = "fin-1207"
    owner       = "payments-platform"
    data-class  = "restricted"
  }

  remove_default_network = true
  deletion_policy        = "PREVENT"
}

# Downstream: point the google provider at the vended project and create
# resources inside it using the module's project_id output.
provider "google" {
  alias   = "payments"
  project = module.project.project_id
  region  = "asia-south1"
}

resource "google_storage_bucket" "artifacts" {
  provider = google.payments
  project  = module.project.project_id

  name                        = "${module.project.project_id}-artifacts"
  location                    = "ASIA-SOUTH1"
  uniform_bucket_level_access = true
  force_destroy               = false
}

# Some IAM service-agent bindings need the numeric project number, not the ID.
resource "google_project_iam_member" "pubsub_agent" {
  project = module.project.project_id
  role    = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member  = "serviceAccount:service-${module.project.project_number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}

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/project/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-project?ref=v1.0.0"
}

inputs = {
  name = "..."
  billing_account = "..."
  environment = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/project && 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
name string Yes Human-readable display name for the project (4-30 chars).
project_id string null No Explicit project ID; if null, the lowercased name (plus optional suffix) is used.
random_project_id bool true No Append a random 4-hex-char suffix to guarantee global uniqueness.
folder_id string null No Parent folder ID (folders/NNN); takes precedence over org_id.
org_id string null No Numeric organization ID; used only when folder_id is null.
billing_account string Yes Billing account ID in the form XXXXXX-XXXXXX-XXXXXX.
environment string Yes Environment label: one of dev, stg, prd, sandbox.
labels map(string) {} No Extra labels merged onto the managed-by/environment defaults.
activate_apis list(string) [compute, iam, logging, monitoring] No Google service APIs to enable on the project.
remove_default_network bool true No Delete the auto-created default VPC.
disable_services_on_destroy bool false No Disable enabled APIs when the project/module is destroyed.
disable_dependent_services bool false No Also disable services that depend on a disabled API.
deletion_policy string "PREVENT" No Project deletion behaviour: PREVENT, ABANDON, or DELETE.

Outputs

Name Description
project_id The globally unique, immutable project ID for downstream providers/resources.
project_number The auto-generated numeric project number (needed for some IAM/service-agent bindings).
name The human-readable display name of the project.
folder_id The parent folder ID (null if created directly under the org).
billing_account The billing account associated with the project.
enabled_apis Sorted list of service APIs this module enabled on the project.

Enterprise scenario

A fintech platform team runs a project factory for ~120 application teams. Each team raises a pull request adding a single module "project" block per environment to the landing-zone repo; CI runs terraform plan, a platform reviewer approves, and Atlantis applies. Every vended project lands in the correct folder (Production, Non-Production, or Sandbox), is force-linked to the central billing account, has its default VPC stripped so workloads must attach to the Shared VPC, and carries cost-center and data-class labels that feed BigQuery billing export dashboards. Because the module is the only creation path, security can prove in an audit that no project exists without billing, labels, or a folder parent.

Best practices

TerraformGCPProjectModuleIaC
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