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:
- How to use Terraform to create a vpc network and a Compute instance in GCP
- Create a Terraform Service Account in GCP (IAM and JSON Key)
- How to Setup Prometheus Monitoring On Kubernetes Cluster
- Deploy to Google Kubernetes Engine Using GitHub Actions
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-prjCredentials Terraform can use. On your own machine, the simplest option is Application Default Credentials:
gcloud auth application-default loginIn 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 leastroles/container.admin,roles/compute.networkAdmin,roles/iam.serviceAccountAdmin,roles/iam.serviceAccountUserandroles/resourcemanager.projectIamAdminfor everything below;roles/editoris 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,
kubectlcannot authenticate to GKE withoutgke-gcloud-auth-plugin. Install both through gcloud:gcloud components install kubectl gke-gcloud-auth-pluginIf you installed gcloud from
aptordnf, install thegoogle-cloud-cli-gke-gcloud-auth-pluginpackage 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_channellets GKE upgrade the control plane and nodes automatically on theREGULARcadence.private_cluster_config.enable_private_nodesgives nodes internal IPs only. The control plane keeps a public endpoint (enable_private_endpoint = false), restricted bymaster_authorized_networks_configto the networks you list. Replace203.0.113.0/24with your own public IP range.workload_identity_configlets Pods use Google service accounts without node keys.deletion_protectiondefaults totruein the google provider, which blocksterraform destroy. It is set tofalsehere 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.
| Standard | Autopilot | |
|---|---|---|
| Nodes | You define node pools and machine types | Managed by Google |
| Billing | Per node VM, whether used or not | Per Pod resource request |
| Node SSH, privileged Pods, custom DaemonSets | Yes | Restricted |
| Best for | Special hardware, tight control, packing many small Pods | Most 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 ...: enablecontainer.googleapis.com(see Requirements) and wait a minute.Cannot destroy cluster because deletion_protection is set to true: setdeletion_protection = false, runterraform apply, thenterraform destroy.kubectltimes out: your IP is not inmaster_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_ADDRESSESandCPUSquotas in the region, or use a zonallocation.
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.