Home› Blog› Week 5 — A Project Factory on Google Cloud: The Co…
GCP Weekly Lab GCP Terraform

A project factory, and a constraint that refuses the wrong project

One module that produces a correct project, and an organization policy that reports an incorrect one — plus what a custom constraint can actually read.

Verified against current vendor documentation on 19 September 2026. Pricing, limits and API behaviour were checked against the official docs on that date. Cloud services change fast — if you are reading this much later, treat the specifics as a starting point and re-check the linked sources.
GCP Platform Engineering Lab · Week 5 of 58

Lab Overview

The problem. Projects in Google Cloud are cheap and disposable, which is exactly why they multiply into a mess: created by hand, named inconsistently, parked at the organization root, with no owner and no contact. Week 1 built a hierarchy; nothing yet makes it hard to create something that does not belong in it.

Objective. Build one reusable module that produces a project which is correct by construction — named, parented, labelled, tagged, with essential contacts and no default network — and an organization policy constraint that reports a project which is not.

Expected outcome. A project built by the factory, verified against the cloud, and a custom constraint in dry run with an honest account of which fields it can actually read.

DifficultyIntermediate — the module is ordinary Terraform; the constraint's field support is not documented
DeploymentTerraform via HCP Terraform remote execution, Workload Identity Federation
Reproduce itLab guide — the four roles, field probing, the billing quota
Cost$0 — projects, tags, contacts and constraints are all free

Cloud Services and Concepts

ServicePurpose in this lab
Resource ManagerCreates the project and places it in a folder. The parent is what makes a project governable.
Resource Manager tagsKey/value pairs with their own IAM, so a closed set of environments can be enforced rather than typed. Different from labels.
LabelsFree-form key/values for billing attribution and search. No IAM, no validation.
Essential ContactsWho Google emails about security and technical notices for this project. Absent by default.
Custom organization policy constraintsA CEL expression evaluated when a resource is created or updated — the mechanism for rules Google does not ship.

Tags and labels are not the same thing, and the difference matters here. A label is free text: anyone who can edit the project can write env=prd, env=production or env=prodcution. A tag is a resource with its own IAM — the key and its permitted values are created once, centrally, and binding one requires a separate permission. If a value must come from a closed set and be governable, it is a tag; if it is for billing breakdown and search, a label is enough. This lab uses both, for those reasons.

Architecture and Workflow

Projects from a template GCP Lab · Week 5 Terraform the module labels · env tag essential contacts no default network a project same shape every time custom constraint checks the id prefix and the parent folder in DRY RUN produces evaluated by a tag is not a label one is a value from a closed set; policy reads it jayanthkatta.com mod:96,190,360,180;con:420,520,460,180

The flow

  1. Create the tag key environment and its two permitted values, so the set is closed before anything can reference it.
  2. The module creates the project in a folder, with auto_create_network = false so the permissive default network never exists.
  3. The module applies labels for billing attribution, binds the environment tag, and sets essential contacts.
  4. A custom constraint evaluates project creation against the project ID prefix and the parent type.
  5. It is deployed in dry run, so it reports violations without denying anything.
  6. Verify the result against the cloud rather than against Terraform state.

Two decisions worth naming

A tag for environment, not a label. The value has to come from a closed set, and tags have their own IAM — tagAdmin creates the permitted values, tagUser binds one. A label would let anyone with project edit write whatever they liked.

Dry run first. A constraint that denies project creation on day one, written against fields whose support is undocumented, is a good way to make the organization unusable. It reports; promotion comes after evidence.

Prerequisites

  • A folder to create into — Week 1 supplies it.
  • A free billing slot. This is the real limit; see Troubleshooting.
  • Four roles on the apply identity that look like one job: projectCreator, tagAdmin, tagUser and essentialcontacts.admin.
  • A quota project with cloudresourcemanager, serviceusage, cloudbilling and essentialcontacts enabled.
  • Terraform 1.9+ with hashicorp/google ~> 6.0.

Exact roles, variables and the field-probing method are in the lab guide.

Implementation

Eleven resources: four at the root and five in a reusable module, plus API enablement. Commands are in the lab guide.

Repository structure showing LAB.md, terraform with a modules/project directory, scripts and docs.
The module is the point of the week — modules/project is what makes the next project correct without thinking about it. Full source on GitHub.

Step 1 — Close the set of environments before using one

Why: a tag key and its permitted values are created centrally and once. After that, a project can only be tagged with a value that already exists — which is the difference between governance and a naming convention.

Resource Manager tag key environment with values dev and prod.
Validates Step 1: the key and exactly two permitted values. Binding one is a separate permission from creating them.

Step 2 — The module builds a project that is correct by construction

Why: every property that makes a project governable — parent, labels, tag, contacts, no default network — is applied together, so there is no window in which the project exists incorrectly.

Google Cloud console showing the new project with its labels and bound environment tag.
Validates Step 2: the project in its folder, carrying labels and the bound tag. auto_create_network = false means the permissive default network and its firewall rules never existed.
Essential Contacts configured with SECURITY and TECHNICAL categories.
Validates Step 2: essential contacts set. Creating a project confers no right to administer them — that is a separate role, which is easy to discover only when the apply fails.

Step 3 — A constraint that reports the wrong project

Why: the module makes the right thing easy; the constraint notices the wrong thing. Action:

resource.projectId.startsWith(prefix) &&
  resource.parent.startsWith("folders/")

Validation: dry run, so violations are logged rather than denied.

Organization policy showing the custom constraint in dry-run mode.
Validates Step 3: the constraint exists and is in dry run. What it can read is the interesting part — see Troubleshooting.
HCP Terraform run showing Applied with eleven resources.
Validates deployment only: eleven resources applied.

Validation and Evidence

TestExpectedActualStatus
Infrastructure deployed11 resources11 added, 0 changed, 0 destroyedPass
The factory's project exists in the right folderparent is workloads/devcorrect parentPass
Labels appliedweek, env, managed-byall threePass
Environment tag bound from the closed setenvironment = devboundPass
Essential contacts setSECURITY and TECHNICALbothPass
No default networkzero VPCs in the projectnonePass
Constraint readable fieldsprobe to find outprojectId and parent accepted; labels and displayName rejectedFinding
The constraint actually denies a bad projectcreation refusednot tested — deployed in dry run, so it denies nothing by designUnverified

Row seven is the week's real finding and it came from probing, not documentation: a custom constraint on a Project can read resource.projectId and resource.parent, and is rejected outright if it references resource.labels or resource.displayName. The supported-services reference lists Project at Preview and never publishes its fields.

Row eight is the honest limit of this week: the constraint is in dry run, so no denial has been observed. Whether it would refuse a badly named project is untested, and calling it a working guardrail would be a claim the evidence does not support.

Security and Cost

Security decisions

ControlDecision and trade-off
No default networkauto_create_network = false, so the permissive default VPC and its default-allow-internal rules never exist. Nothing has to be cleaned up afterwards.
Closed-set environmentsA tag with its own IAM rather than a free-text label, so the value cannot be invented by anyone with project edit.
AccountabilityEssential contacts are set at creation; a project without them receives no security notices from Google.
Least privilegeFour narrow roles rather than a basic role — and the create-versus-bind split for tags is enforced by Google, not by convention.
Accepted gapThe constraint is in dry run and denies nothing. It is a reporting control at this stage, not a preventive one.

For production, the constraint would be promoted out of dry run with a remediation plan for existing violations, and the module would validate the prefix and parent itself so failures surface at plan time rather than at apply.

Cost

$0. Projects, tag keys and values, essential contacts and custom constraints are all free. Nothing in this week runs.

Troubleshooting and Lessons

A constraint cannot read the labels of the project it is evaluating

Problem: a constraint referencing resource.labels failed with Error 400: Request has invalid values, naming nothing.

Root cause: the Project resource exposes projectId and parent to custom constraints and does not expose labels or displayName. Project is listed at Preview and its fields are not published.

Resolution: probe each field and keep the two that are accepted.

Lesson: probe before designing a constraint on an unfamiliar resource. An expression written against an unsupported field looks correct, passes review, and fails at apply with an error that names neither the field nor the reason.

Creating a tag and binding a tag are different roles

Problem: the apply created the tag key and values, then failed binding one to the project.

Root cause: tagAdmin carries no tagValueBindings.create; that is tagUser. The same create-versus-attach split appeared in Week 04 with firewall policies.

Lesson: in GCP, creating a thing and attaching it are routinely separate permissions. Expect to need both.

The real limit on a project factory is a billing quota

Problem: Cloud billing quota exceeded at five linked projects.

Root cause: the billing account caps how many projects may be linked, and a deleted project holds its slot for 30 days.

Resolution: unlink an unused project — and note that only the owning identity can unlink a project that sits outside the organization; an organization admin cannot.

Lesson: the constraint that stops a project factory is not Terraform or IAM. Check the quota before building something that creates projects on demand.

Cleanup and Key Takeaways

Cleanup

terraform destroy removes the project, the tag key and values, and the constraint. Verify against the cloud: the project should report DELETE_REQUESTED and the tag key should be gone.

Two things to expect. A deleted project holds its billing slot for 30 days, so destroying does not immediately free capacity. And an enforced constraint can make a resource un-reconcilable with its own configuration — Week 04's spoke failed to destroy because Terraform wanted to remove metadata that a by-then-enforced constraint refused to let go. Commands are in the lab guide.

Ongoing charges: none.

Key takeaways

  1. Probe a custom constraint's fields before designing around them. A Project exposes projectId and parent; labels and displayName are rejected, and the documentation does not say so.
  2. Tags are governable; labels are not. If a value must come from a closed set, it is a tag — with its own IAM and a separate permission to bind.
  3. Creating and attaching are different permissions in GCP, repeatedly. Expect to need both halves.
  4. A project factory is limited by a billing quota, not by code — five linked projects here, and a deleted project holds its slot for 30 days.

References

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent