2026-07-30 · 8 min read
GCP Landing Zone from Scratch with Terraform
Building a production-grade Google Cloud foundation, resource hierarchy, shared VPC with Cloud NAT, a project factory, and org-policy guardrails. All Terraform, CI-validated.

Most teams start GCP with a single project. Click around the console, enable some APIs, spin up a VM. It works, until the second team needs access, or someone accidentally deletes the production VPC, or you realise there's no audit trail for who created what. That's the moment you wish you'd built a landing zone on day zero.
I built gcp-landing-zone-terraform to be that day-zero foundation: a fully codified GCP organisation setup covering resource hierarchy, networking, project vending, and policy guardrails. Everything is Terraform, CI-validated on every push.
Why a Landing Zone, Not Just a Project
A landing zone answers the questions you'll inevitably face at scale:
- Where do workloads live? A hierarchy of folders and projects with clear ownership.
- How do services talk to each other? A shared VPC with consistent subnet design.
- How do I onboard a new team? A project factory that vends pre-configured projects in minutes.
- What's forbidden? Organisation policies that enforce guardrails before anyone can break them.
Without this, you end up with snowflake projects, inconsistent networking, and retroactive compliance firefighting. The landing zone is the platform your teams build on.
Resource Hierarchy
GCP's resource model is a tree: Organisation → Folders → Projects. I structured mine like this:
durrellgemuh.com (org)
├── Bootstrap/ # Terraform state, CI service accounts
├── Common/ # Shared services (logging, DNS, monitoring)
├── Production/
│ ├── Networking/ # Host project (shared VPC)
│ └── Workloads/ # Service projects (GKE, Cloud Run, etc.)
├── Staging/
│ ├── Networking/
│ └── Workloads/
└── Sandbox/ # Developer experimentation
Each folder maps to a clear purpose. IAM bindings attach at the folder level, so permissions cascade predictably. The Bootstrap folder holds the Terraform state bucket and the CI service account, it's the only thing provisioned manually (once).
In Terraform, the hierarchy is straightforward:
resource "google_folder" "production" {
display_name = "Production"
parent = "organizations/${var.org_id}"
}
resource "google_folder" "prod_networking" {
display_name = "Networking"
parent = google_folder.production.name
}
resource "google_folder" "prod_workloads" {
display_name = "Workloads"
parent = google_folder.production.name
}
Shared VPC Design
Instead of giving every project its own VPC (which becomes an IP management nightmare), I use GCP's Shared VPC model. A host project owns the network; service projects attach to it and deploy workloads into designated subnets.
Network layout
| Subnet | CIDR | Purpose | Region |
|---|---|---|---|
prod-gke-nodes | 10.0.0.0/20 | GKE node pools | europe-west1 |
prod-gke-pods | 10.4.0.0/14 | GKE pods (secondary range) | europe-west1 |
prod-gke-services | 10.8.0.0/20 | GKE services (secondary range) | europe-west1 |
prod-general | 10.10.0.0/22 | VMs, Cloud Run connectors | europe-west1 |
prod-data | 10.10.4.0/22 | Cloud SQL, Memorystore | europe-west1 |
All subnets use Private Google Access so workloads reach Google APIs without public IPs. External egress routes through Cloud NAT: no instance needs a public IP.
resource "google_compute_network" "shared_vpc" {
name = "shared-vpc"
project = google_project.host_project.project_id
auto_create_subnetworks = false
routing_mode = "GLOBAL"
}
resource "google_compute_subnetwork" "prod_general" {
name = "prod-general"
project = google_project.host_project.project_id
network = google_compute_network.shared_vpc.id
ip_cidr_range = "10.10.0.0/22"
region = "europe-west1"
private_ip_google_access = true
}
resource "google_compute_router_nat" "nat" {
name = "cloud-nat"
project = google_project.host_project.project_id
router = google_compute_router.router.name
region = "europe-west1"
nat_ip_allocate_option = "AUTO_ONLY"
source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
log_config {
enable = true
filter = "ERRORS_ONLY"
}
}
Service projects are attached with google_compute_shared_vpc_service_project, and subnet-level IAM grants give each team access only to their designated subnets.
The Project Factory
Manually creating projects is slow and inconsistent. The project factory is a reusable Terraform module that vends new projects with a standard baseline: APIs enabled, IAM bindings applied, budget alerts configured, and the project attached to the shared VPC.
module "workload_project" {
source = "./modules/project-factory"
project_name = "payments-api"
folder_id = google_folder.prod_workloads.name
billing_account = var.billing_account_id
shared_vpc_host = google_project.host_project.project_id
activate_apis = [
"compute.googleapis.com",
"container.googleapis.com",
"sqladmin.googleapis.com",
]
iam_bindings = {
"roles/editor" = ["group:payments-team@durrellgemuh.com"]
}
budget_amount = 500
labels = {
team = "payments"
environment = "production"
cost_centre = "eng-platform"
}
}
Internally the module calls google_project, google_project_service, google_project_iam_member, google_billing_budget, and the shared VPC attachment. One tfvars file per project, one PR to onboard a team. Reviewing the diff is the approval process.
Organisation Policies
Org policies are the preventive controls. They enforce rules at the org or folder level, no one can bypass them from inside a project, not even a project owner. Here are the four I always deploy:
1. Restrict VM external IPs
No public IPs on compute instances. All egress goes through Cloud NAT.
resource "google_org_policy_policy" "no_external_ip" {
name = "organizations/${var.org_id}/policies/compute.vmExternalIpAccess"
parent = "organizations/${var.org_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
2. Restrict resource locations
Keep data in approved regions for compliance.
resource "google_org_policy_policy" "resource_locations" {
name = "organizations/${var.org_id}/policies/gcp.resourceLocations"
parent = "organizations/${var.org_id}"
spec {
rules {
values {
allowed_values = ["in:europe-west1-locations", "in:europe-west4-locations"]
}
}
}
}
3. Disable default service account creation
Forces teams to create purpose-built service accounts with least privilege.
resource "google_org_policy_policy" "disable_default_sa" {
name = "organizations/${var.org_id}/policies/iam.automaticIamGrantsForDefaultServiceAccounts"
parent = "organizations/${var.org_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
4. Require uniform bucket-level access
Prevents legacy ACL confusion on Cloud Storage.
resource "google_org_policy_policy" "uniform_bucket_access" {
name = "organizations/${var.org_id}/policies/storage.uniformBucketLevelAccess"
parent = "organizations/${var.org_id}"
spec {
rules {
enforce = "TRUE"
}
}
}
These four alone prevent the majority of accidental exposure I've seen in production GCP environments.
CI Validation
Every change to the landing zone runs through a GitHub Actions pipeline:
terraform fmt -check: style consistencyterraform validate: syntax correctnessterraform plan: preview changes against the real org (using Workload Identity Federation, no long-lived keys)- Manual approval →
terraform apply
The plan output is posted as a PR comment so reviewers see exactly what will change. No one applies infrastructure without a reviewed diff.
Lessons Learned
Start with the hierarchy, not the network. If your folder structure is wrong, fixing it later means moving projects and re-binding IAM. Get the org design right first.
Over-allocate CIDR ranges. I've never regretted giving a subnet too much space, but I've regretted the opposite. /20 minimums for anything that might grow.
Org policies first, exceptions later. Deploy the constraint org-wide, then add folder-level exceptions for sandbox. It's far safer than trying to opt projects in one by one.
The project factory pays for itself on the second project. The upfront investment in a reusable module feels heavy for one project. By the third team onboarding, it's pure leverage.
Terraform state isolation matters. Each layer (bootstrap, hierarchy, networking, projects) gets its own state file. A bad plan in networking shouldn't risk your org-level resources.
The full implementation, modules, CI pipeline, and example configurations, is on GitHub: github.com/durrello/gcp-landing-zone-terraform.