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
- You operate a landing zone / resource hierarchy (Org → Folders → Projects) and need every new workload project created the same way.
- You run a project-per-environment pattern (e.g.
app-dev,app-stg,app-prd) and want billing, labels, and APIs identical across them. - Platform/landing-zone teams running a project factory where application teams request a project via PR rather than clicking in the console.
- You need deterministic, collision-resistant project IDs (GCP project IDs are globally unique and immutable) and want a random suffix handled for you.
- You want the default network removed automatically so Shared VPC / explicit networking is the only path.
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 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/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
- Pin to a folder, not the org root. Always set
folder_idso org-level IAM and policy inheritance (org policies, hierarchical firewall) apply. Reserve org-root projects for the landing zone itself. - Keep
random_project_id = truefor non-production to avoid global ID collisions across teams, but pin a stableproject_idfor production projects whose ID appears in DNS, IAM principals, or external allowlists. - Enable APIs explicitly and keep
disable_on_destroy = false. Listing APIs inactivate_apismakes attack surface auditable, while not disabling on destroy avoids cascading failures when sibling configs still depend on a shared API. - Always remove the default network in landing-zone projects so nothing inadvertently gets a public IP or permissive
default-allow-*firewall rules; force all connectivity through Shared VPC. - Use
deletion_policy = "PREVENT"for prd and stg to stop an accidentalterraform destroyfrom deleting a billing-bearing project; reserveDELETEforsandbox. - Label for cost from day one. The merged
managed-by/environment/cost-centerlabels are what billing export and FinOps dashboards group on — retrofitting labels onto hundreds of resources later is painful.