← Back to blog

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.

#gcp#terraform#landing-zone#networking#iac#cloud
GCP Landing Zone from Scratch with Terraform

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

SubnetCIDRPurposeRegion
prod-gke-nodes10.0.0.0/20GKE node poolseurope-west1
prod-gke-pods10.4.0.0/14GKE pods (secondary range)europe-west1
prod-gke-services10.8.0.0/20GKE services (secondary range)europe-west1
prod-general10.10.0.0/22VMs, Cloud Run connectorseurope-west1
prod-data10.10.4.0/22Cloud SQL, Memorystoreeurope-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:

  1. terraform fmt -check: style consistency
  2. terraform validate: syntax correctness
  3. terraform plan: preview changes against the real org (using Workload Identity Federation, no long-lived keys)
  4. 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.

Share:LinkedInXWhatsApp

Related articles

Reactions & comments