Create a VPC Network and GKE Cluster in GCP with Terraform

Create a GCP VPC and a private GKE cluster with Terraform: google provider 8.x, node pool, Cloud NAT, Autopilot option, gke-gcloud-auth-plugin and kubectl.

In this guide, we will build a Google Kubernetes Engine (GKE) cluster in Google Cloud Platform using Terraform. Terraform allows you to develop cloud infrastructure by automating repetitive tasks.

Creating a GKE cluster in the console can be tiring, especially if you have to create multiple clusters with different parameters such as node types, node sizes etc. Terraform was created to solve that problem. It allows you to have the instructions as code that can be used to plan, deploy, modify, and destroy the clusters programmatically.

We will create:

  • A custom VPC network with a public and a private subnet, with secondary ranges for Pods and Services (a VPC-native cluster).
  • A Cloud Router and Cloud NAT, so nodes without public IPs can still reach the internet.
  • A regional GKE Standard cluster with private nodes, a release channel and Workload Identity.
  • A separately managed node pool running as a dedicated, least-privilege service account.

We will also look at the Autopilot alternative, connect with kubectl, and go over what it costs.

Everything here works the same with OpenTofu: replace terraform with tofu.

Check out these:

Requirements

You need the following to proceed:

  • A Google Cloud project with billing enabled. GCP organizes resources into projects. Create one in the GCP console and make note of the project ID.

  • The Compute Engine and Kubernetes Engine APIs enabled for the project:

    gcloud services enable compute.googleapis.com container.googleapis.com --project citizix-prj
    
  • Credentials Terraform can use. On your own machine, the simplest option is Application Default Credentials:

    gcloud auth application-default login
    

    In CI, use a service account. This guide covers it: Create a Terraform Service Account in GCP. Point Terraform at its key with export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json, or better, use Workload Identity Federation so there is no long-lived key at all. The account needs at least roles/container.admin, roles/compute.networkAdmin, roles/iam.serviceAccountAdmin, roles/iam.serviceAccountUser and roles/resourcemanager.projectIamAdmin for everything below; roles/editor is too broad for anything but a throwaway project, and cannot grant IAM roles anyway.

Step 1 – Install Terraform, gcloud and kubectl

  • Terraform 1.6 or later from the official install page, or OpenTofu.

  • gcloud CLI from the Google Cloud SDK install guide.

  • kubectl and the GKE auth plugin. Since Kubernetes 1.26, kubectl cannot authenticate to GKE without gke-gcloud-auth-plugin. Install both through gcloud:

    gcloud components install kubectl gke-gcloud-auth-plugin
    

    If you installed gcloud from apt or dnf, install the google-cloud-cli-gke-gcloud-auth-plugin package instead. Check it works:

    gke-gcloud-auth-plugin --version
    

Step 2 – Adding the project code

In this section we will create the files that will contain the code for our resources. First create a directory and switch to it:

mkdir gcp-gke
cd gcp-gke

Providers and locals

First we will have to specify the providers. Terraform relies on plugins called “providers” to interact with cloud providers, SaaS providers, and other APIs. Terraform configurations must declare which providers they require so that Terraform can install and use them.

This is main.tf, where we define the google provider and pin it to the current major version, 8.x. Provider majors change defaults and remove arguments, so ~> 8.0 lets you pick up fixes without an unplanned jump to 9.0. We are also defining some locals that we can reuse.

A local value assigns a name to an expression, so you can use the name multiple times within a module instead of repeating the expression. Local values are like a function’s temporary local variables.

locals {
  env        = "dev"
  project    = "citizix"
  region     = "europe-west1"
  project_id = "citizix-prj"
}

terraform {
  required_version = ">= 1.6"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 8.0"
    }
  }
}

provider "google" {
  project = local.project_id
  region  = local.region
}

There is no credentials argument: the provider picks up Application Default Credentials or GOOGLE_APPLICATION_CREDENTIALS on its own, which keeps the key path out of your code.

Create the VPC

Next we need a VPC, because all the other resources depend on it. The following code creates a network with two subnetworks, one public and one private. The private subnet carries two secondary ranges: GKE assigns Pod IPs from pods and Service IPs from services, which makes the cluster VPC-native.

Because the nodes will be private (no external IPs), they need Cloud NAT to pull images from registries outside Google, such as Docker Hub. Save it as vpc.tf:

locals {
  vpc_name = "${local.env}-${local.project}-vpc"
}

resource "google_compute_network" "vpc" {
  name                    = local.vpc_name
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "public" {
  name          = "${local.vpc_name}-public-0"
  region        = local.region
  network       = google_compute_network.vpc.id
  ip_cidr_range = "10.1.0.0/24"
}

resource "google_compute_subnetwork" "private" {
  name                     = "${local.vpc_name}-private-0"
  region                   = local.region
  network                  = google_compute_network.vpc.id
  ip_cidr_range            = "10.1.1.0/24"
  private_ip_google_access = true

  secondary_ip_range {
    range_name    = "pods"
    ip_cidr_range = "10.20.0.0/16"
  }

  secondary_ip_range {
    range_name    = "services"
    ip_cidr_range = "10.30.0.0/20"
  }
}

# Private nodes have no public IP, so they need Cloud NAT to pull images from outside Google
resource "google_compute_router" "router" {
  name    = "${local.vpc_name}-router"
  region  = local.region
  network = google_compute_network.vpc.id
}

resource "google_compute_router_nat" "nat" {
  name                               = "${local.vpc_name}-nat"
  router                             = google_compute_router.router.name
  region                             = local.region
  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"
}

Size the pods range generously. By default each node reserves a /24 from it (110 Pods per node), so a /16 gives you room for about 256 nodes.

Create the GKE cluster

Next we can create our cluster. We only want to use separately managed node pools, so we create the smallest possible default node pool and immediately delete it with remove_default_node_pool. The cluster goes in the private subnet.

  • release_channel lets GKE upgrade the control plane and nodes automatically on the REGULAR cadence.
  • private_cluster_config.enable_private_nodes gives nodes internal IPs only. The control plane keeps a public endpoint (enable_private_endpoint = false), restricted by master_authorized_networks_config to the networks you list. Replace 203.0.113.0/24 with your own public IP range.
  • workload_identity_config lets Pods use Google service accounts without node keys.
  • deletion_protection defaults to true in the google provider, which blocks terraform destroy. It is set to false here so you can tear the tutorial down; leave it on for anything you care about.

The node pool runs as its own service account with only the roles nodes need, instead of the Compute Engine default service account (which has Editor on the project).

Add this content to gke.tf:

locals {
  cluster_name  = "${local.env}-${local.project}-gke"
  gke_num_nodes = 1
}

resource "google_service_account" "gke_nodes" {
  account_id   = "${local.cluster_name}-nodes"
  display_name = "GKE nodes for ${local.cluster_name}"
}

resource "google_project_iam_member" "gke_nodes" {
  for_each = toset([
    "roles/container.defaultNodeServiceAccount",
    "roles/artifactregistry.reader",
  ])

  project = local.project_id
  role    = each.value
  member  = google_service_account.gke_nodes.member
}

resource "google_container_cluster" "gke" {
  name     = local.cluster_name
  location = local.region

  remove_default_node_pool = true
  initial_node_count       = 1
  deletion_protection      = false

  network    = google_compute_network.vpc.id
  subnetwork = google_compute_subnetwork.private.id

  release_channel {
    channel = "REGULAR"
  }

  ip_allocation_policy {
    cluster_secondary_range_name  = "pods"
    services_secondary_range_name = "services"
  }

  private_cluster_config {
    enable_private_nodes    = true
    enable_private_endpoint = false
  }

  master_authorized_networks_config {
    cidr_blocks {
      cidr_block   = "203.0.113.0/24"
      display_name = "office"
    }
  }

  workload_identity_config {
    workload_pool = "${local.project_id}.svc.id.goog"
  }
}

# Separately Managed Node Pool
resource "google_container_node_pool" "gke_nodes" {
  name       = "${local.cluster_name}-node-pool"
  location   = local.region
  cluster    = google_container_cluster.gke.name
  node_count = local.gke_num_nodes

  node_config {
    machine_type    = "e2-medium"
    disk_size_gb    = 50
    service_account = google_service_account.gke_nodes.email
    oauth_scopes    = ["https://www.googleapis.com/auth/cloud-platform"]
    # spot          = true

    labels = {
      env = local.env
    }

    tags = ["gke-node", "${local.project}-gke"]

    workload_metadata_config {
      mode = "GKE_METADATA"
    }
  }

  management {
    auto_repair  = true
    auto_upgrade = true
  }
}

output "gke_endpoint" {
  value     = google_container_cluster.gke.endpoint
  sensitive = true
}

output "gke_master_version" {
  value = google_container_cluster.gke.master_version
}

output "gke_node_pool_instance_groups" {
  value = google_container_node_pool.gke_nodes.instance_group_urls
}

A note on location: setting it to a region creates a regional cluster with a control plane and nodes in three zones, and node_count is per zone. gke_num_nodes = 1 therefore gives you three nodes. Set location to a zone like europe-west1-b for a cheaper single-zone cluster.

On google provider 8.x, terraform validate warns that remove_default_node_pool is deprecated. On GKE 1.36.4-gke.1469000 and later you can drop both remove_default_node_pool and initial_node_count and the cluster is created without a default pool. The pattern above still works on every GKE version, so it is the safe default until your release channel is past that version.

Step 3 – Autopilot instead of Standard

With Autopilot, Google manages the nodes for you: no node pools, no machine types, no node upgrades. You pay for the CPU, memory and storage your Pods request instead of for VMs. For most workloads that do not need custom node configuration (GPUs aside, privileged DaemonSets, specific kernels), it is the simpler choice.

To use Autopilot, replace the cluster, node pool, and node service account in gke.tf with:

resource "google_container_cluster" "autopilot" {
  name     = "${local.env}-${local.project}-autopilot"
  location = local.region

  enable_autopilot    = true
  deletion_protection = false

  network    = google_compute_network.vpc.id
  subnetwork = google_compute_subnetwork.private.id

  ip_allocation_policy {
    cluster_secondary_range_name  = "pods"
    services_secondary_range_name = "services"
  }

  private_cluster_config {
    enable_private_nodes    = true
    enable_private_endpoint = false
  }

  master_authorized_networks_config {
    cidr_blocks {
      cidr_block   = "203.0.113.0/24"
      display_name = "office"
    }
  }
}

Autopilot clusters are always regional and already have Workload Identity, auto-repair and auto-upgrade turned on, so those blocks are not needed.

StandardAutopilot
NodesYou define node pools and machine typesManaged by Google
BillingPer node VM, whether used or notPer Pod resource request
Node SSH, privileged Pods, custom DaemonSetsYesRestricted
Best forSpecial hardware, tight control, packing many small PodsMost apps, small teams

Step 4 – Planning and applying changes

First initialize Terraform to download the required providers:

terraform init

Then format and validate to ensure that you have valid code without errors:

terraform fmt
terraform validate

For the Standard cluster, validate prints the deprecation warning described above, followed by:

Success! The configuration is valid, but there were some validation warnings
as shown above.

Then plan to confirm that the changes being introduced are what is expected, and save the plan:

terraform plan -out=tf.plan

Finally apply that saved plan to create the resources in GCP. A saved plan is applied without a confirmation prompt, since you already reviewed it:

terraform apply tf.plan

Creating a GKE cluster takes roughly 5 to 15 minutes. Delete tf.plan afterwards: saved plans can contain sensitive values and do not belong in Git.

Step 5 – Connecting to the cluster

Use gcloud to fetch cluster credentials into your kubeconfig. The region must match local.region:

gcloud container clusters get-credentials dev-citizix-gke \
  --region europe-west1 --project citizix-prj
Fetching cluster endpoint and auth data.
kubeconfig entry generated for dev-citizix-gke.

If this fails with gke-gcloud-auth-plugin ... not found, go back to step 1 and install the plugin.

Then check the cluster:

kubectl cluster-info
kubectl get nodes -o wide

You should see three Ready nodes (one per zone) with an internal IP and no external IP, which confirms the nodes are private. On Autopilot, kubectl get nodes may show no nodes until you deploy something.

Run a quick test workload to confirm the nodes can pull from Docker Hub through Cloud NAT:

kubectl create deployment hello --image=nginx:stable
kubectl rollout status deployment/hello
kubectl get pods -l app=hello -o wide
kubectl delete deployment hello

The Pod IP comes from the pods secondary range (10.20.0.0/16). If the Pod is stuck in ImagePullBackOff, Cloud NAT is missing or not covering the subnet.

If you manage several clusters, see How to merge multiple kubeconfig files into one.

Troubleshooting

  • Error 403: Kubernetes Engine API has not been used in project ...: enable container.googleapis.com (see Requirements) and wait a minute.
  • Cannot destroy cluster because deletion_protection is set to true: set deletion_protection = false, run terraform apply, then terraform destroy.
  • kubectl times out: your IP is not in master_authorized_networks_config. Add it and apply.
  • Quota errors on create: a regional cluster needs three times the CPUs and IPs of a zonal one. Check IN_USE_ADDRESSES and CPUS quotas in the region, or use a zonal location.

Cost

GKE is not free to leave running. You pay for:

  • A per-cluster management fee. The GKE free tier credit covers one zonal or Autopilot cluster per billing account; regional Standard clusters pay it.
  • The node VMs (Standard) or Pod resource requests (Autopilot), plus their persistent disks.
  • Cloud NAT gateway hours and processed data, and any load balancers your Services create.

Check the current numbers on the GKE pricing page. For a tutorial, a zonal cluster with one e2-medium node (or spot = true in node_config) keeps the bill small. When you are done:

terraform destroy

Conclusion

We were able to use Terraform to create a VPC and a private GKE cluster in GCP, with the option of Autopilot, and connect to it with kubectl. This allows us to create and destroy resources easily, at the same time bringing in the benefits of having infrastructure as code.

comments powered by Disqus
Citizix Ltd
Built with Hugo
Theme Stack designed by Jimmy