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
- You want browser- or local-IDE access to container-based dev environments that live inside your VPC, reaching private Cloud SQL, internal APIs, or on-prem over Interconnect without exposing anything publicly.
- You need a reproducible golden image (pre-installed SDKs, linters, internal CA, company dotfiles) shared across a team instead of “works on my machine” laptops.
- You care about data residency and exfiltration control: source never leaves the perimeter, the gateway can be private, and the home disk is CMEK-encrypted.
- You want cost control by default — idle workstations auto-stop, and you bill per running hour rather than for always-on VMs.
- You onboard contractors or offshore teams and want a revocable, audited dev surface you can grant and remove with one IAM binding.
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 config — live/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 config — live/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
- Keep the cluster private and the VMs IP-less. Leave
private_cluster = trueanddisable_public_ip_addresses = trueso the gateway is reachable only through IAP and the VPC, and route egress through Cloud NAT — a Cloud Workstation should never be on the public internet, and source should never leave the perimeter. - Ship a golden image, never the public base. Point
container_imageat a versioned Artifact Registry image (tagged by date or commit, e.g.backend-go:2026.06) with your CA, linters, and SDK pins baked in, so every developer gets an identical, scanned environment and you can roll forward and back deterministically. - Auto-stop everything for cost. Always set a tight
idle_timeout_seconds(workstations bill per running hour) and arunning_timeout_secondsceiling so a forgotten box on a largemachine_typecannot run indefinitely; reserve a non-zeropool_sizeonly for teams that need sub-minute starts, since pooled VMs cost while warm. - Persist and encrypt /home, but mind the reclaim policy. Use a
home_disk_size_gbpersistent directory so cloned repos and IDE state survive a stop, wrap it with akms_key(CMEK) for key control, and keephome_disk_reclaim_policy = "RETAIN"for regulated data so disks are not silently destroyed — pair RETAIN with a documented cleanup process to avoid orphaned-disk cost. - Run as a dedicated least-privilege SA. Keep
create_service_account = trueand grant that identity only what a workstation needs (Artifact Registry reader, specific Secret Manager secrets) — never reuse the default Compute Engine SA, and harden the VM withenable_secure_boot(andenable_confidential_computeon n2d-class machines for sensitive data). - Standardize naming and grant access by group. Name clusters and configs by domain and stack (
platform-dev/backend-go), populatelabelswithteam/environmentfor billing-export and Cloud Monitoring slicing, and put Google Groups (not individual users) inworkstation_usersso onboarding and offboarding are a single membership change.