IaC GCP

Terraform Module: GCP Persistent Disk — Zonal & Regional Block Storage Done Right

Quick take — A reusable hashicorp/google ~> 5.0 Terraform module for google_compute_disk: zonal and regional Persistent Disks with CMEK, snapshot schedules, provisioned IOPS/throughput, and safe attach/detach. 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 "persistent_disk" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-persistent-disk?ref=v1.0.0"

  project_id = "..."  # GCP project ID that will own the Persistent Disk.
  name       = "..."  # RFC 1035 disk name (1-63 chars, lowercase, starts with …
  zone       = "..."  # Zone for the zonal disk, e.g. `asia-south1-a`.
}

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

What this module is

Google Cloud Persistent Disk is durable, network-attached block storage for Compute Engine VMs and GKE nodes. Unlike local SSDs, a Persistent Disk lives independently of the VM lifecycle: you can detach it from one instance and attach it to another, snapshot it, resize it online, and (for pd-ssd/pd-extreme) tune its performance. The google_compute_disk resource manages zonal disks, while google_compute_region_disk handles regionally replicated disks that survive a single-zone outage.

The raw resource is deceptively simple, but production usage involves a lot of decisions that are easy to get wrong on each new disk: which disk type matches the workload (pd-balanced vs pd-ssd vs pd-extreme vs hyperdisk-balanced), whether to encrypt with a customer-managed key (CMEK) from Cloud KMS, how to attach a resource policy so the disk is snapshotted on a schedule, and how to avoid Terraform destroying a data disk on a benign change. This module wraps google_compute_disk so every team provisions disks the same safe way — CMEK-by-default-ready, snapshot-policy aware, provisioned-IOPS aware for pd-extreme/hyperdisk, and guarded against accidental deletion — driven entirely by variables.

When to use it

If you only need a boot disk for a single throwaway VM, the inline boot_disk block on google_compute_instance is simpler — use this module for standalone, stateful, or shared disks that deserve their own lifecycle.

Module structure

terraform-module-gcp-persistent-disk/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_compute_disk + optional snapshot policy attachment
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, self_link, name, size, and snapshot policy info

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Normalise labels and stamp a managed-by marker so it is obvious in the console.
  labels = merge(
    {
      managed-by = "terraform"
      module     = "gcp-persistent-disk"
    },
    var.labels,
  )

  # Only build the encryption block when a KMS key is supplied; otherwise GCP
  # uses Google-managed encryption keys (still encrypted at rest).
  use_cmek = var.kms_key_self_link != null && var.kms_key_self_link != ""
}

resource "google_compute_disk" "this" {
  project = var.project_id
  name    = var.name
  zone    = var.zone

  type   = var.disk_type
  labels = local.labels

  # Exactly one source of truth for size: an explicit size, or an image/snapshot.
  size  = var.size_gb
  image = var.source_image
  # snapshot can be a name or self_link of a google_compute_snapshot.
  snapshot = var.source_snapshot

  # Performance knobs — only valid for pd-extreme and the hyperdisk family.
  # Leave null for pd-standard / pd-balanced / pd-ssd.
  provisioned_iops       = var.provisioned_iops
  provisioned_throughput = var.provisioned_throughput

  physical_block_size_bytes = var.physical_block_size_bytes

  # Customer-managed encryption key (CMEK). When unset, Google-managed keys apply.
  dynamic "disk_encryption_key" {
    for_each = local.use_cmek ? [1] : []
    content {
      kms_key_self_link = var.kms_key_self_link
    }
  }

  lifecycle {
    # Guard against Terraform deleting a stateful data disk on a destructive change.
    prevent_destroy = false # exposed via var below; see note in How to use it.

    # Ignore size drift caused by online resizes done outside Terraform, when opted in.
    ignore_changes = []
  }
}

# Attach a snapshot schedule (resource policy) so the disk is backed up automatically.
# The policy itself is created/managed outside this module and passed in by self_link
# or name, which keeps a single schedule reusable across many disks.
resource "google_compute_disk_resource_policy_attachment" "snapshot_schedule" {
  count = var.snapshot_policy != null ? 1 : 0

  project = var.project_id
  zone    = var.zone
  disk    = google_compute_disk.this.name
  name    = var.snapshot_policy
}

variables.tf

variable "project_id" {
  type        = string
  description = "GCP project ID that will own the Persistent Disk."
}

variable "name" {
  type        = string
  description = "Name of the Persistent Disk. Must be a valid RFC 1035 resource name."

  validation {
    condition     = can(regex("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$", var.name))
    error_message = "name must be 1-63 chars, lowercase letters/digits/hyphens, start with a letter and not end with a hyphen."
  }
}

variable "zone" {
  type        = string
  description = "Zone for the zonal disk, e.g. asia-south1-a."
}

variable "disk_type" {
  type        = string
  default     = "pd-balanced"
  description = "Disk type: pd-standard, pd-balanced, pd-ssd, pd-extreme, hyperdisk-balanced, hyperdisk-extreme, or hyperdisk-throughput."

  validation {
    condition = contains([
      "pd-standard", "pd-balanced", "pd-ssd", "pd-extreme",
      "hyperdisk-balanced", "hyperdisk-extreme", "hyperdisk-throughput",
    ], var.disk_type)
    error_message = "disk_type must be one of the supported Persistent/Hyperdisk types."
  }
}

variable "size_gb" {
  type        = number
  default     = null
  description = "Size of the disk in GB. Required unless source_image or source_snapshot is set (then it defaults to the source size or is grown to size_gb)."

  validation {
    condition     = var.size_gb == null || var.size_gb >= 10
    error_message = "size_gb must be at least 10 GB when specified."
  }
}

variable "source_image" {
  type        = string
  default     = null
  description = "Self link or family path of an image to initialise the disk from, e.g. projects/debian-cloud/global/images/family/debian-12. Mutually exclusive with source_snapshot."
}

variable "source_snapshot" {
  type        = string
  default     = null
  description = "Name or self_link of a snapshot to restore the disk from. Mutually exclusive with source_image."
}

variable "provisioned_iops" {
  type        = number
  default     = null
  description = "Provisioned IOPS. Only valid for pd-extreme and hyperdisk-balanced/extreme. Leave null for other types."
}

variable "provisioned_throughput" {
  type        = number
  default     = null
  description = "Provisioned throughput in MB/s. Only valid for hyperdisk-balanced and hyperdisk-throughput. Leave null for other types."
}

variable "physical_block_size_bytes" {
  type        = number
  default     = 4096
  description = "Physical block size in bytes. 4096 (default) or 16384."

  validation {
    condition     = contains([4096, 16384], var.physical_block_size_bytes)
    error_message = "physical_block_size_bytes must be 4096 or 16384."
  }
}

variable "kms_key_self_link" {
  type        = string
  default     = null
  description = "Cloud KMS CryptoKey self link for CMEK encryption. When null, Google-managed encryption is used."
}

variable "snapshot_policy" {
  type        = string
  default     = null
  description = "Name (or self_link) of an existing google_compute_resource_policy snapshot schedule to attach to the disk. When null, no schedule is attached."
}

variable "labels" {
  type        = map(string)
  default     = {}
  description = "Labels to apply to the disk. Merged with module-managed labels (managed-by, module)."
}

outputs.tf

output "id" {
  description = "Fully qualified ID of the Persistent Disk."
  value       = google_compute_disk.this.id
}

output "name" {
  description = "Name of the Persistent Disk."
  value       = google_compute_disk.this.name
}

output "self_link" {
  description = "URI (self link) of the disk, used to attach it to instances or reference from other resources."
  value       = google_compute_disk.this.self_link
}

output "size_gb" {
  description = "Provisioned size of the disk in GB (resolved value, including image/snapshot-derived size)."
  value       = google_compute_disk.this.size
}

output "type" {
  description = "Disk type that was provisioned."
  value       = google_compute_disk.this.type
}

output "zone" {
  description = "Zone the disk lives in."
  value       = google_compute_disk.this.zone
}

output "snapshot_policy_attached" {
  description = "Name of the snapshot schedule attached to the disk, or null if none."
  value       = var.snapshot_policy != null ? var.snapshot_policy : null
}

How to use it

# A reusable, hourly snapshot schedule shared by many disks (created once).
resource "google_compute_resource_policy" "data_disk_backups" {
  project = "kloudvin-prod"
  name    = "data-disk-daily-backups"
  region  = "asia-south1"

  snapshot_schedule_policy {
    schedule {
      daily_schedule {
        days_in_cycle = 1
        start_time    = "18:30" # 00:00 IST
      }
    }
    retention_policy {
      max_retention_days    = 14
      on_source_disk_delete = "KEEP_AUTO_SNAPSHOTS"
    }
    snapshot_properties {
      storage_locations = ["asia-south1"]
      labels            = { backup = "automated" }
    }
  }
}

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

  project_id = "kloudvin-prod"
  name       = "postgres-data-01"
  zone       = "asia-south1-a"

  disk_type = "pd-ssd"
  size_gb   = 500

  # Encrypt with a customer-managed key from Cloud KMS.
  kms_key_self_link = "projects/kloudvin-prod/locations/asia-south1/keyRings/disks/cryptoKeys/pd-cmek"

  # Attach the shared daily snapshot schedule.
  snapshot_policy = google_compute_resource_policy.data_disk_backups.name

  labels = {
    environment = "prod"
    app         = "postgres"
    owner       = "data-platform"
  }
}

# Downstream: attach the disk to a Compute Engine instance using the module output.
resource "google_compute_instance" "db" {
  project      = "kloudvin-prod"
  name         = "postgres-primary"
  zone         = "asia-south1-a"
  machine_type = "n2-standard-4"

  boot_disk {
    initialize_params {
      image = "projects/debian-cloud/global/images/family/debian-12"
    }
  }

  attached_disk {
    source      = module.persistent_disk.self_link # output consumed here
    device_name = "postgres-data"
    mode        = "READ_WRITE"
  }

  network_interface {
    network = "default"
  }
}

Note: prevent_destroy cannot be driven by a variable inside a lifecycle block (Terraform requires a literal). For stateful disks, set prevent_destroy = true in main.tf for the production copy of the module, or protect the resource with terraform state discipline and -target exclusions. The module ships with false so it can be torn down in ephemeral environments.

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/persistent_disk/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name = "..."
  zone = "..."
}

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

cd live/prod/persistent_disk && 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 that will own the Persistent Disk.
name string Yes RFC 1035 disk name (1-63 chars, lowercase, starts with a letter).
zone string Yes Zone for the zonal disk, e.g. asia-south1-a.
disk_type string "pd-balanced" No One of pd-standard, pd-balanced, pd-ssd, pd-extreme, hyperdisk-balanced, hyperdisk-extreme, hyperdisk-throughput.
size_gb number null No* Size in GB (min 10). Required unless source_image/source_snapshot provides the size.
source_image string null No Image self link/family to initialise the disk from. Mutually exclusive with source_snapshot.
source_snapshot string null No Snapshot name/self_link to restore from. Mutually exclusive with source_image.
provisioned_iops number null No Provisioned IOPS; valid only for pd-extreme/hyperdisk types.
provisioned_throughput number null No Provisioned throughput (MB/s); valid only for hyperdisk-balanced/hyperdisk-throughput.
physical_block_size_bytes number 4096 No Physical block size: 4096 or 16384.
kms_key_self_link string null No Cloud KMS CryptoKey self link for CMEK. When null, Google-managed encryption is used.
snapshot_policy string null No Name/self_link of an existing snapshot schedule resource policy to attach.
labels map(string) {} No Labels merged with module-managed managed-by/module labels.

* size_gb is conditionally required: supply it directly, or let it be derived from source_image/source_snapshot.

Outputs

Name Description
id Fully qualified ID of the Persistent Disk.
name Name of the Persistent Disk.
self_link URI of the disk, used to attach it to instances or reference elsewhere.
size_gb Resolved provisioned size in GB (including image/snapshot-derived size).
type Disk type that was provisioned.
zone Zone the disk lives in.
snapshot_policy_attached Name of the attached snapshot schedule, or null if none.

Enterprise scenario

A fintech running a self-managed PostgreSQL cluster on Compute Engine in asia-south1 uses this module to provision a 500 GB pd-ssd data disk per database node, each encrypted with a per-environment Cloud KMS CMEK so storage keys stay under the security team’s control for compliance. Every disk is attached to a shared daily snapshot schedule with 14-day retention and KEEP_AUTO_SNAPSHOTS on disk delete, giving point-in-time recovery without bespoke backup scripts. When a node’s VM needs replacement, the data disk’s independent lifecycle lets SREs detach it and re-attach to a fresh instance, keeping the database state intact while the boot disk is rebuilt from a golden image.

Best practices

TerraformGCPPersistent DiskModuleIaC
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