IaC GCP

Terraform Module: GCP Cloud Workstations — Managed, Hardened Dev Environments in One Block

Quick take — A reusable hashicorp/google ~> 5.0 module for google_workstations_workstation_cluster: private VPC clusters, a workstation config with custom container image, idle/running-timeout auto-shutdown, encrypted persistent home disk, and least-privilege IAM. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "cloud_workstations" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-workstations?ref=v1.0.0"

  project_id = "..."  # GCP project ID hosting the cluster.
  name       = "..."  # Cluster id; RFC1035, lowercase, <= 63 chars.
  location   = "..."  # Region, e.g. `asia-south1`.
  network    = "..."  # VPC network self-link the cluster attaches to.
  subnetwork = "..."  # Regional subnet self-link for workstation VMs.
  config_id  = "..."  # Workstation config (template) id; RFC1035.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Cloud Workstations is GCP’s managed development environment service. Instead of every engineer running a laptop-local toolchain (or a hand-built VM that drifts over time), Cloud Workstations gives each developer an on-demand, container-based dev box that runs inside your VPC, boots from an image you control, and is reachable through the browser-based code editor or over SSH/local IDE through a secured tunnel. The control plane lives in three layers: a cluster that binds a region and a VPC subnet, a config (the template) that pins the machine type, the container image, the persistent home disk, and the auto-shutdown timeouts, and the workstations themselves, which are cheap to start and stop and bill only while running.

The reason to wrap it in a module is that a safe Cloud Workstations deployment is almost never just a cluster. In production you want: a private cluster (no public gateway, so the only path in is through Identity-Aware Proxy and your VPC), a config that auto-stops idle boxes so a forgotten workstation does not bill a full e2-standard-8 over a weekend, a CMEK-encrypted persistent home directory so source and credentials survive a stop and are encrypted with your own key, a golden container image from Artifact Registry rather than the public base, and a least-privilege runtime service account the workstation assumes — not the default Compute SA with broad project rights. Hand-writing the three nested resources for every team means everyone re-derives the timeout, disk, and IAM settings, and the security-sensitive ones get quietly wrong.

This module wires google_workstations_workstation_cluster, google_workstations_workstation_config, and an optional default google_workstations_workstation into one variable-driven block. It defaults to a private cluster, mounts a reclaimable encrypted home disk, sets idle and running timeouts, runs a custom image under a dedicated service account, and grants developer IAM through roles/workstations.user. It exposes the cluster and config IDs plus the cluster’s private control-plane hostname as outputs so DNS, firewall, and IAP resources downstream can consume them.

When to use it

Skip it if a fully managed laptop fleet (MDM) already meets your needs, if your workloads need GPUs/architectures Cloud Workstations does not offer in your region, or if developers genuinely require root on the host VM rather than inside a container.

Module structure

terraform-module-gcp-cloud-workstations/
├── versions.tf      # provider + required_version pins
├── main.tf          # runtime SA, cluster, config, optional workstation, IAM
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # cluster id/name, config id, control-plane host, SA email

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # SA account_id: 6-30 chars, lowercase, must start with a letter.
  service_account_id = substr("${var.name}-ws", 0, 30)
}

# Dedicated identity the workstation VM assumes. Scope its roles narrowly
# (e.g. Artifact Registry reader, Secret Manager accessor) outside this module.
resource "google_service_account" "workstation" {
  count = var.create_service_account ? 1 : 0

  project      = var.project_id
  account_id   = local.service_account_id
  display_name = "Cloud Workstations runtime SA for ${var.name}"
  description  = "Identity assumed by workstations in the ${var.name} config."
}

locals {
  workstation_sa_email = var.create_service_account ? google_service_account.workstation[0].email : var.service_account_email
}

# The cluster binds a region + VPC subnet. Private clusters expose no public
# gateway: the only ingress is via IAP + the private control-plane endpoint.
resource "google_workstations_workstation_cluster" "this" {
  provider               = google
  project                = var.project_id
  workstation_cluster_id = var.name
  location               = var.location
  network                = var.network
  subnetwork             = var.subnetwork

  labels      = var.labels
  annotations = var.annotations

  dynamic "private_cluster_config" {
    for_each = var.private_cluster ? [1] : []
    content {
      enable_private_endpoint = true
      allowed_projects        = var.allowed_projects
    }
  }
}

# The config is the reusable template: machine type, image, disk, timeouts.
resource "google_workstations_workstation_config" "this" {
  provider               = google
  project                = var.project_id
  workstation_cluster_id = google_workstations_workstation_cluster.this.workstation_cluster_id
  workstation_config_id  = var.config_id
  location               = var.location

  labels      = var.labels
  annotations = var.annotations

  # Auto-shutdown levers. idle_timeout stops a box after inactivity;
  # running_timeout caps total uptime regardless of activity.
  idle_timeout    = "${var.idle_timeout_seconds}s"
  running_timeout = "${var.running_timeout_seconds}s"

  # Optional CMEK envelope encryption for the boot/ephemeral disk.
  dynamic "encryption_key" {
    for_each = var.kms_key == null ? [] : [1]
    content {
      kms_key                 = var.kms_key
      kms_key_service_account = local.workstation_sa_email
    }
  }

  host {
    gce_instance {
      machine_type                = var.machine_type
      boot_disk_size_gb           = var.boot_disk_size_gb
      disable_public_ip_addresses = var.disable_public_ip_addresses
      pool_size                   = var.pool_size
      service_account             = local.workstation_sa_email
      service_account_scopes      = ["https://www.googleapis.com/auth/cloud-platform"]
      tags                        = var.network_tags

      shielded_instance_config {
        enable_secure_boot          = var.enable_secure_boot
        enable_vtpm                 = true
        enable_integrity_monitoring = true
      }

      confidential_instance_config {
        enable_confidential_compute = var.enable_confidential_compute
      }
    }
  }

  # Persistent /home so source, IDE state, and credentials survive a stop.
  dynamic "persistent_directories" {
    for_each = var.home_disk_size_gb == null ? [] : [1]
    content {
      mount_path = "/home"
      gce_pd {
        size_gb         = var.home_disk_size_gb
        fs_type         = "ext4"
        disk_type       = var.home_disk_type
        reclaim_policy  = var.home_disk_reclaim_policy
        source_snapshot = var.home_disk_source_snapshot
      }
    }
  }

  # The dev environment image. Default is GCP's base code-oss; in production
  # point this at a golden image in Artifact Registry.
  container {
    image       = var.container_image
    command     = var.container_command
    args        = var.container_args
    working_dir = var.container_working_dir
    env         = var.container_env
    run_as_user = var.run_as_user
  }
}

# Optionally pre-create a named workstation in the config.
resource "google_workstations_workstation" "default" {
  count = var.create_default_workstation ? 1 : 0

  provider               = google
  project                = var.project_id
  workstation_cluster_id = google_workstations_workstation_cluster.this.workstation_cluster_id
  workstation_config_id  = google_workstations_workstation_config.this.workstation_config_id
  workstation_id         = var.default_workstation_id
  location               = var.location
  labels                 = var.labels
}

# Grant developers the right to start and connect to workstations in this config.
resource "google_workstations_workstation_config_iam_member" "users" {
  for_each = toset(var.workstation_users)

  provider               = google
  project                = var.project_id
  location               = var.location
  workstation_cluster_id = google_workstations_workstation_cluster.this.workstation_cluster_id
  workstation_config_id  = google_workstations_workstation_config.this.workstation_config_id
  role                   = "roles/workstations.user"
  member                 = each.value
}

variables.tf

variable "project_id" {
  description = "GCP project ID that hosts the workstation cluster."
  type        = string
}

variable "name" {
  description = "Workstation cluster id (RFC1035: lowercase, start with a letter, hyphens allowed; <= 63 chars)."
  type        = string

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]*[a-z0-9])?$", var.name)) && length(var.name) <= 63
    error_message = "name must be lowercase RFC1035 (start with a letter, hyphens allowed) and <= 63 chars."
  }
}

variable "location" {
  description = "Region for the cluster and config, e.g. asia-south1, europe-west1, us-central1."
  type        = string
}

variable "network" {
  description = "Self-link of the VPC network the cluster attaches to (projects/<p>/global/networks/<n>)."
  type        = string
}

variable "subnetwork" {
  description = "Self-link of the regional subnet in var.network used by workstation instances."
  type        = string
}

variable "config_id" {
  description = "Workstation config id (RFC1035, <= 63 chars). The reusable dev-environment template."
  type        = string

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]*[a-z0-9])?$", var.config_id)) && length(var.config_id) <= 63
    error_message = "config_id must be lowercase RFC1035 and <= 63 chars."
  }
}

variable "private_cluster" {
  description = "When true, expose only a private control-plane endpoint (no public gateway). Strongly recommended."
  type        = bool
  default     = true
}

variable "allowed_projects" {
  description = "Projects allowed to reach the private endpoint via PSC (in addition to var.project_id)."
  type        = list(string)
  default     = []
}

variable "machine_type" {
  description = "Compute machine type for each workstation VM, e.g. e2-standard-4, n2-standard-8."
  type        = string
  default     = "e2-standard-4"
}

variable "boot_disk_size_gb" {
  description = "Boot disk size in GB for the workstation VM (>= 30)."
  type        = number
  default     = 50

  validation {
    condition     = var.boot_disk_size_gb >= 30
    error_message = "boot_disk_size_gb must be at least 30."
  }
}

variable "pool_size" {
  description = "Number of pre-warmed, stopped VMs kept ready to cut workstation start time (0 disables)."
  type        = number
  default     = 0

  validation {
    condition     = var.pool_size >= 0 && var.pool_size <= 50
    error_message = "pool_size must be between 0 and 50."
  }
}

variable "idle_timeout_seconds" {
  description = "Stop a workstation after this many seconds of inactivity (300-86400). Key cost control."
  type        = number
  default     = 1200

  validation {
    condition     = var.idle_timeout_seconds >= 300 && var.idle_timeout_seconds <= 86400
    error_message = "idle_timeout_seconds must be between 300 and 86400."
  }
}

variable "running_timeout_seconds" {
  description = "Force-stop a workstation after this much total uptime regardless of activity (0 = no cap, else 300-86400)."
  type        = number
  default     = 43200

  validation {
    condition     = var.running_timeout_seconds == 0 || (var.running_timeout_seconds >= 300 && var.running_timeout_seconds <= 86400)
    error_message = "running_timeout_seconds must be 0 or between 300 and 86400."
  }
}

variable "disable_public_ip_addresses" {
  description = "When true, workstation VMs get no external IP (egress via Cloud NAT). Recommended."
  type        = bool
  default     = true
}

variable "enable_secure_boot" {
  description = "Enable Shielded VM Secure Boot on workstation VMs."
  type        = bool
  default     = true
}

variable "enable_confidential_compute" {
  description = "Enable Confidential VM (memory encryption). Requires a supported machine type (e.g. n2d)."
  type        = bool
  default     = false
}

variable "network_tags" {
  description = "Network tags applied to workstation VMs for firewall targeting."
  type        = list(string)
  default     = []
}

variable "container_image" {
  description = "Dev-environment container image. Default is GCP's base code editor; point at an Artifact Registry golden image in production."
  type        = string
  default     = "us-central1-docker.pkg.dev/cloud-workstations-images/predefined/code-oss:latest"
}

variable "container_command" {
  description = "Override the image entrypoint. Empty keeps the image default."
  type        = list(string)
  default     = []
}

variable "container_args" {
  description = "Arguments passed to the container entrypoint."
  type        = list(string)
  default     = []
}

variable "container_working_dir" {
  description = "Working directory inside the container. Null keeps the image default."
  type        = string
  default     = null
}

variable "container_env" {
  description = "Environment variables injected into the workstation container."
  type        = map(string)
  default     = {}
}

variable "run_as_user" {
  description = "UID the container runs as. 0 keeps the image default user."
  type        = number
  default     = 0
}

variable "home_disk_size_gb" {
  description = "Size of the persistent /home disk in GB. Null disables the persistent directory (ephemeral home)."
  type        = number
  default     = 200

  validation {
    condition     = var.home_disk_size_gb == null || var.home_disk_size_gb >= 10
    error_message = "home_disk_size_gb must be null or at least 10."
  }
}

variable "home_disk_type" {
  description = "Persistent /home disk type: pd-standard, pd-balanced, or pd-ssd."
  type        = string
  default     = "pd-balanced"

  validation {
    condition     = contains(["pd-standard", "pd-balanced", "pd-ssd"], var.home_disk_type)
    error_message = "home_disk_type must be one of pd-standard, pd-balanced, pd-ssd."
  }
}

variable "home_disk_reclaim_policy" {
  description = "What happens to the /home disk when the workstation is deleted: RETAIN or DELETE."
  type        = string
  default     = "RETAIN"

  validation {
    condition     = contains(["RETAIN", "DELETE"], var.home_disk_reclaim_policy)
    error_message = "home_disk_reclaim_policy must be RETAIN or DELETE."
  }
}

variable "home_disk_source_snapshot" {
  description = "Optional snapshot self-link to seed the /home disk from (e.g. a baseline workspace)."
  type        = string
  default     = null
}

variable "kms_key" {
  description = "Cloud KMS CryptoKey self-link for CMEK encryption of the ephemeral disk. Null uses Google-managed keys."
  type        = string
  default     = null
}

variable "create_service_account" {
  description = "Create a dedicated runtime SA assumed by the workstation VMs."
  type        = bool
  default     = true
}

variable "service_account_email" {
  description = "Existing runtime SA email to use when create_service_account is false."
  type        = string
  default     = null
}

variable "workstation_users" {
  description = "IAM members granted roles/workstations.user (start/connect) on this config, e.g. group:devs@corp.com."
  type        = list(string)
  default     = []
}

variable "create_default_workstation" {
  description = "When true, pre-create one named workstation in the config."
  type        = bool
  default     = false
}

variable "default_workstation_id" {
  description = "Workstation id to create when create_default_workstation is true."
  type        = string
  default     = "ws-default"
}

variable "labels" {
  description = "Labels applied to the cluster, config, and workstation."
  type        = map(string)
  default     = {}
}

variable "annotations" {
  description = "Annotations applied to the cluster and config."
  type        = map(string)
  default     = {}
}

outputs.tf

output "cluster_id" {
  description = "Fully qualified workstation cluster resource id."
  value       = google_workstations_workstation_cluster.this.id
}

output "cluster_name" {
  description = "Workstation cluster id (short name)."
  value       = google_workstations_workstation_cluster.this.workstation_cluster_id
}

output "control_plane_host" {
  description = "Private control-plane hostname clients connect through (for DNS/IAP wiring)."
  value       = google_workstations_workstation_cluster.this.control_plane_ip
}

output "config_id" {
  description = "Fully qualified workstation config resource id."
  value       = google_workstations_workstation_config.this.id
}

output "config_name" {
  description = "Workstation config id (short name)."
  value       = google_workstations_workstation_config.this.workstation_config_id
}

output "service_account_email" {
  description = "Runtime service account email the workstation VMs assume."
  value       = local.workstation_sa_email
}

output "default_workstation_id" {
  description = "Resource id of the pre-created workstation, or null when none was created."
  value       = var.create_default_workstation ? google_workstations_workstation.default[0].id : null
}

How to use it

module "cloud_workstations" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-workstations?ref=v1.0.0"

  project_id = "kv-platform-prod"
  name       = "platform-dev"
  config_id  = "backend-go"
  location   = "asia-south1"

  network    = "projects/kv-platform-prod/global/networks/dev-vpc"
  subnetwork = "projects/kv-platform-prod/regions/asia-south1/subnetworks/dev-workstations"

  # Private gateway, no external IP on the VMs — egress goes through Cloud NAT.
  private_cluster             = true
  disable_public_ip_addresses = true

  # Golden image from Artifact Registry with the Go toolchain + internal CA baked in.
  machine_type    = "n2-standard-8"
  container_image = "asia-south1-docker.pkg.dev/kv-platform-prod/workstations/backend-go:2026.06"

  # Persistent encrypted home so cloned repos and IDE state survive a stop.
  home_disk_size_gb = 200
  kms_key           = "projects/kv-platform-prod/locations/asia-south1/keyRings/workstations/cryptoKeys/home"

  # Cost guardrails: stop after 20 min idle, hard cap at 12 h of uptime.
  idle_timeout_seconds    = 1200
  running_timeout_seconds = 43200

  workstation_users = ["group:backend-devs@kloudvin.com"]

  labels = {
    team        = "platform"
    environment = "dev"
  }
}

# Downstream: a firewall rule that lets workstation VMs reach private Cloud SQL,
# scoped by the config id surfaced from the module's outputs.
resource "google_compute_firewall" "ws_to_cloudsql" {
  project   = "kv-platform-prod"
  name      = "allow-${module.cloud_workstations.config_name}-to-cloudsql"
  network   = "dev-vpc"
  direction = "EGRESS"

  destination_ranges = ["10.40.0.0/24"] # Cloud SQL private IP range

  allow {
    protocol = "tcp"
    ports    = ["5432"]
  }

  target_service_accounts = [module.cloud_workstations.service_account_email]
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/cloud_workstations/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-cloud-workstations?ref=v1.0.0"
}

inputs = {
  project_id = "..."
  name = "..."
  location = "..."
  network = "..."
  subnetwork = "..."
  config_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/cloud_workstations && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string yes GCP project ID hosting the cluster.
name string yes Cluster id; RFC1035, lowercase, <= 63 chars.
location string yes Region, e.g. asia-south1.
network string yes VPC network self-link the cluster attaches to.
subnetwork string yes Regional subnet self-link for workstation VMs.
config_id string yes Workstation config (template) id; RFC1035.
private_cluster bool true no Private control-plane endpoint only (no public gateway).
allowed_projects list(string) [] no Extra projects allowed to the private endpoint via PSC.
machine_type string e2-standard-4 no Compute machine type per workstation VM.
boot_disk_size_gb number 50 no Boot disk size in GB (>= 30).
pool_size number 0 no Pre-warmed stopped VMs to cut start time (0-50).
idle_timeout_seconds number 1200 no Auto-stop after inactivity (300-86400).
running_timeout_seconds number 43200 no Hard uptime cap; 0 disables, else 300-86400.
disable_public_ip_addresses bool true no No external IP on workstation VMs (use Cloud NAT).
enable_secure_boot bool true no Shielded VM Secure Boot.
enable_confidential_compute bool false no Confidential VM memory encryption (needs n2d-class).
network_tags list(string) [] no Network tags for firewall targeting.
container_image string code-oss:latest no Dev-environment image; use an Artifact Registry golden image.
container_command list(string) [] no Override the image entrypoint.
container_args list(string) [] no Arguments to the container entrypoint.
container_working_dir string null no Working directory inside the container.
container_env map(string) {} no Environment variables in the workstation container.
run_as_user number 0 no UID the container runs as (0 = image default).
home_disk_size_gb number 200 no Persistent /home disk size; null = ephemeral home.
home_disk_type string pd-balanced no /home disk type: pd-standard / pd-balanced / pd-ssd.
home_disk_reclaim_policy string RETAIN no On workstation delete: RETAIN or DELETE the disk.
home_disk_source_snapshot string null no Snapshot self-link to seed /home.
kms_key string null no CMEK CryptoKey for ephemeral-disk encryption.
create_service_account bool true no Create a dedicated runtime SA.
service_account_email string null no Existing runtime SA email when not creating one.
workstation_users list(string) [] no IAM members granted roles/workstations.user.
create_default_workstation bool false no Pre-create one named workstation.
default_workstation_id string ws-default no Id of the pre-created workstation.
labels map(string) {} no Labels on cluster/config/workstation.
annotations map(string) {} no Annotations on cluster/config.

Outputs

Name Description
cluster_id Fully qualified workstation cluster resource id.
cluster_name Workstation cluster short id.
control_plane_host Private control-plane host clients connect through.
config_id Fully qualified workstation config resource id.
config_name Workstation config short id.
service_account_email Runtime SA email the workstation VMs assume.
default_workstation_id Resource id of the pre-created workstation, or null.

Enterprise scenario

A fintech platform team needs to onboard a 30-person offshore squad without ever letting regulated customer data touch an unmanaged laptop. They deploy this module once per stack (backend-go, data-py) into a private cluster on the shared dev-vpc, each config pinned to a hardened Artifact Registry image carrying the internal root CA and pre-approved SDK versions. Because disable_public_ip_addresses and private_cluster are on, the only path in is IAP plus corporate SSO, egress is forced through Cloud NAT and a logged firewall, and idle_timeout_seconds = 1200 means a forgotten n2-standard-8 stops itself after twenty minutes instead of billing through the weekend. When a contract ends, removing the engineer from the backend-devs group in workstation_users instantly revokes start/connect rights, and the CMEK-encrypted /home disk can be retained for audit or destroyed on a policy schedule.

Best practices

TerraformGCPCloud WorkstationsModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading