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
- You need a data disk (separate from the boot disk) for a database, message broker, or stateful service and want it to outlive VM re-creation.
- You are standardising disk provisioning across many teams and want consistent labels, CMEK encryption, and snapshot schedules without copy-pasting HCL.
- You run GKE or self-managed stateful workloads and pre-provision disks that get attached via
google_compute_attached_diskor referenced by aPersistentVolume. - You need high-IOPS storage (
pd-extremeorhyperdisk-balanced) and want to setprovisioned_iops/provisioned_throughputdeclaratively. - You want disks created from an existing snapshot or image (clone a golden volume, restore from backup) in a repeatable way.
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_destroycannot be driven by a variable inside alifecycleblock (Terraform requires a literal). For stateful disks, setprevent_destroy = trueinmain.tffor the production copy of the module, or protect the resource withterraform statediscipline and-targetexclusions. The module ships withfalseso 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 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/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
- Separate data from boot. Keep stateful data on a standalone Persistent Disk (this module), never on the VM’s boot disk, so VM re-creation never destroys your data. Set
prevent_destroy = truefor production data disks. - Encrypt with CMEK where compliance requires it. Pass
kms_key_self_linkfor regulated workloads; grant the Compute Engine service agentroles/cloudkms.cryptoKeyEncrypterDecrypteron the key, and remember that rotating to a new key version re-encrypts new data only. - Attach a snapshot schedule, don’t script backups. Use
snapshot_policyto bind a resource policy with sensible retention (max_retention_days) andon_source_disk_delete = KEEP_AUTO_SNAPSHOTSso backups survive an accidental disk deletion. - Right-size the disk type for cost and IOPS.
pd-balancedis the sane default; reservepd-ssd/pd-extreme/hyperdiskfor latency- or IOPS-bound databases — and note thatpd-standard/pd-balancedIOPS scale with size, so undersizing throttles throughput. - Resize online, never shrink. Persistent Disks can only grow; bump
size_gband run the in-guest filesystem resize, but plan capacity generously because Terraform cannot reduce a disk later. - Label consistently for cost allocation. Always set
environment,app, andownerlabels so disk spend (and orphaned, unattached disks) shows up cleanly in billing exports and BigQuery cost reports.