IaC GCP

Terraform Module: GCP Cloud KMS — Governed Key Rings with Rotation and IAM Baked In

Quick take — A reusable hashicorp/google ~> 5.0 Terraform module for GCP Cloud KMS that provisions a key ring and multiple crypto keys with automatic rotation, protection levels, IAM bindings, and prevent_destroy guards. 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 "kms" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-kms?ref=v1.0.0"

  project_id    = "..."  # GCP project that owns the key ring and keys.
  key_ring_name = "..."  # Immutable key ring name (1–63 chars, validated).
  location      = "..."  # KMS location, e.g. `global`, `us-central1`, `asia-south…
}

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

What this module is

Google Cloud KMS is a managed cryptographic service where you create and use keys without ever holding the raw key material. Keys live inside a key ring (a regional container) and each crypto key has a purpose — symmetric encryption, asymmetric signing, asymmetric decryption, or MAC — plus a protection level (SOFTWARE, HSM, or EXTERNAL). Almost every other GCP service that supports customer-managed encryption keys (CMEK) — Cloud Storage, BigQuery, Compute persistent disks, Cloud SQL, Pub/Sub, Secret Manager — takes a Cloud KMS crypto key resource ID as its kms_key_name.

The raw resources are deceptively simple, but the correct production setup is not. A key ring is immutable and can never be deleted — once created, it and its keys are permanent (Terraform destroy only removes them from state, the cloud object lingers). Rotation periods have a minimum and a specific format, HSM keys are only available in certain regions, and granting the right roles/cloudkms.cryptoKeyEncrypterDecrypter to the right service agent is what actually makes CMEK work. This module wraps google_kms_key_ring and google_kms_crypto_key so teams get rotation, protection level, destroy-protection, and IAM bindings consistently instead of hand-rolling them per project and forgetting the rotation schedule.

When to use it

Skip it for a one-off throwaway key in a sandbox — but remember that even sandbox key rings are permanent, so a module that enforces naming is still worth it.

Module structure

terraform-module-gcp-kms/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # key ring, crypto keys, IAM bindings
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # key ring + per-key IDs and self-links

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Normalise the key map so every key has a fully-resolved set of attributes,
  # falling back to module-level defaults where a per-key value is omitted.
  crypto_keys = {
    for name, cfg in var.crypto_keys : name => {
      purpose          = try(cfg.purpose, "ENCRYPT_DECRYPT")
      algorithm        = try(cfg.algorithm, null)
      protection_level = try(cfg.protection_level, var.default_protection_level)
      rotation_period  = try(cfg.rotation_period, var.default_rotation_period)
      labels           = merge(var.labels, try(cfg.labels, {}))
      # Rotation is only valid for ENCRYPT_DECRYPT keys; force null otherwise.
      effective_rotation = (
        try(cfg.purpose, "ENCRYPT_DECRYPT") == "ENCRYPT_DECRYPT"
        ? try(cfg.rotation_period, var.default_rotation_period)
        : null
      )
    }
  }

  # Flatten { key_name => [members] } into a set of binding objects for for_each.
  key_iam_bindings = merge([
    for key_name, members in var.key_iam_encrypters : {
      for member in members :
      "${key_name}::${member}" => {
        key_name = key_name
        member   = member
      }
    }
  ]...)
}

resource "google_kms_key_ring" "this" {
  project  = var.project_id
  name     = var.key_ring_name
  location = var.location

  # A key ring (and its keys) can NEVER be deleted in GCP. This guard stops
  # an accidental `terraform destroy` from silently orphaning crypto material.
  lifecycle {
    prevent_destroy = true
  }
}

resource "google_kms_crypto_key" "this" {
  for_each = local.crypto_keys

  key_ring        = google_kms_key_ring.this.id
  name            = each.key
  purpose         = each.value.purpose
  rotation_period = each.value.effective_rotation
  labels          = each.value.labels

  version_template {
    protection_level = each.value.protection_level
    algorithm = coalesce(
      each.value.algorithm,
      each.value.purpose == "ENCRYPT_DECRYPT" ? "GOOGLE_SYMMETRIC_ENCRYPTION" : null
    )
  }

  # Like the key ring, crypto keys are permanent. Protect against destroy.
  lifecycle {
    prevent_destroy = true
  }
}

# Grant service agents (or users) encrypt/decrypt on a specific key. This is
# what makes CMEK actually work for Storage, BigQuery, Compute, etc.
resource "google_kms_crypto_key_iam_member" "encrypters" {
  for_each = local.key_iam_bindings

  crypto_key_id = google_kms_crypto_key.this[each.value.key_name].id
  role          = "roles/cloudkms.cryptoKeyEncrypterDecrypter"
  member        = each.value.member
}

variables.tf

variable "project_id" {
  description = "GCP project ID that will own the key ring and keys."
  type        = string
}

variable "key_ring_name" {
  description = "Name of the key ring. Immutable once created."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9_-]{1,63}$", var.key_ring_name))
    error_message = "key_ring_name must be 1-63 chars: letters, numbers, hyphens, underscores."
  }
}

variable "location" {
  description = "KMS location (e.g. 'global', 'us-central1', 'europe-west1', 'asia-south1'). HSM keys require a regional/multi-regional location, not all regions support them."
  type        = string

  validation {
    condition     = length(var.location) > 0
    error_message = "location must not be empty."
  }
}

variable "crypto_keys" {
  description = <<-EOT
    Map of crypto keys to create, keyed by key name. Each value may set:
      purpose          - ENCRYPT_DECRYPT | ASYMMETRIC_SIGN | ASYMMETRIC_DECRYPT | MAC
      algorithm        - optional; defaults to GOOGLE_SYMMETRIC_ENCRYPTION for ENCRYPT_DECRYPT
      protection_level - SOFTWARE | HSM | EXTERNAL (defaults to default_protection_level)
      rotation_period  - e.g. "7776000s" (90d); only honoured for ENCRYPT_DECRYPT
      labels           - per-key labels, merged over module labels
  EOT
  type = map(object({
    purpose          = optional(string, "ENCRYPT_DECRYPT")
    algorithm        = optional(string)
    protection_level = optional(string)
    rotation_period  = optional(string)
    labels           = optional(map(string), {})
  }))
  default = {}

  validation {
    condition = alltrue([
      for k, v in var.crypto_keys :
      contains(["ENCRYPT_DECRYPT", "ASYMMETRIC_SIGN", "ASYMMETRIC_DECRYPT", "MAC"], v.purpose)
    ])
    error_message = "Each crypto key purpose must be one of ENCRYPT_DECRYPT, ASYMMETRIC_SIGN, ASYMMETRIC_DECRYPT, MAC."
  }

  validation {
    condition = alltrue([
      for k, v in var.crypto_keys :
      v.protection_level == null || contains(["SOFTWARE", "HSM", "EXTERNAL"], v.protection_level)
    ])
    error_message = "protection_level, when set, must be SOFTWARE, HSM, or EXTERNAL."
  }
}

variable "default_protection_level" {
  description = "Protection level applied to keys that do not set their own."
  type        = string
  default     = "SOFTWARE"

  validation {
    condition     = contains(["SOFTWARE", "HSM", "EXTERNAL"], var.default_protection_level)
    error_message = "default_protection_level must be SOFTWARE, HSM, or EXTERNAL."
  }
}

variable "default_rotation_period" {
  description = "Default automatic rotation period for symmetric keys, as a seconds-suffixed duration (e.g. '7776000s' = 90 days). Minimum allowed by GCP is 86400s (24h). Set to null to disable rotation by default."
  type        = string
  default     = "7776000s"

  validation {
    condition = (
      var.default_rotation_period == null ||
      can(regex("^[0-9]+s$", var.default_rotation_period))
    )
    error_message = "default_rotation_period must be a seconds-suffixed string like '7776000s', or null."
  }
}

variable "key_iam_encrypters" {
  description = <<-EOT
    Map of key name => list of IAM members granted roles/cloudkms.cryptoKeyEncrypterDecrypter
    on that key. Use service agent identities for CMEK, e.g.
    "serviceAccount:service-123@gs-project-accounts.iam.gserviceaccount.com".
  EOT
  type    = map(list(string))
  default = {}
}

variable "labels" {
  description = "Labels applied to every crypto key in the ring."
  type        = map(string)
  default     = {}
}

outputs.tf

output "key_ring_id" {
  description = "Fully-qualified key ring ID (projects/<p>/locations/<l>/keyRings/<n>)."
  value       = google_kms_key_ring.this.id
}

output "key_ring_name" {
  description = "Short name of the key ring."
  value       = google_kms_key_ring.this.name
}

output "location" {
  description = "Location of the key ring."
  value       = google_kms_key_ring.this.location
}

output "crypto_key_ids" {
  description = "Map of key name => fully-qualified crypto key ID, suitable for kms_key_name on CMEK-enabled resources."
  value       = { for name, key in google_kms_crypto_key.this : name => key.id }
}

output "crypto_keys" {
  description = "Map of key name => { id, purpose, protection_level } for downstream consumers."
  value = {
    for name, key in google_kms_crypto_key.this : name => {
      id               = key.id
      purpose          = key.purpose
      protection_level = key.version_template[0].protection_level
    }
  }
}

How to use it

# Look up the Cloud Storage service agent so we can grant it decrypt rights.
data "google_storage_project_service_account" "gcs" {
  project = var.project_id
}

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

  project_id    = var.project_id
  key_ring_name = "platform-prod-keyring"
  location      = "asia-south1"

  default_protection_level = "HSM"
  default_rotation_period  = "7776000s" # 90 days

  crypto_keys = {
    "gcs-data" = {
      purpose         = "ENCRYPT_DECRYPT"
      rotation_period = "2592000s" # 30 days for hot data
    }
    "bigquery-analytics" = {
      purpose = "ENCRYPT_DECRYPT"
    }
    "app-signing" = {
      purpose          = "ASYMMETRIC_SIGN"
      algorithm        = "EC_SIGN_P256_SHA256"
      protection_level = "HSM"
    }
  }

  key_iam_encrypters = {
    "gcs-data" = [
      "serviceAccount:${data.google_storage_project_service_account.gcs.email_address}"
    ]
  }

  labels = {
    environment = "prod"
    owner       = "platform-security"
  }
}

# Downstream: a CMEK-encrypted bucket using one of the module's key outputs.
resource "google_storage_bucket" "data" {
  name     = "kloudvin-prod-data"
  project  = var.project_id
  location = "ASIA-SOUTH1"

  encryption {
    default_kms_key_name = module.cloud_kms.crypto_key_ids["gcs-data"]
  }

  # Bucket creation fails unless the GCS service agent already has decrypt
  # rights on the key, so depend on the IAM binding the module created.
  depends_on = [module.cloud_kms]
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  key_ring_name = "..."
  location = "..."
}

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

cd live/prod/kms && 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 that owns the key ring and keys.
key_ring_name string Yes Immutable key ring name (1–63 chars, validated).
location string Yes KMS location, e.g. global, us-central1, asia-south1.
crypto_keys map(object) {} No Keys to create, keyed by name; each sets purpose, algorithm, protection level, rotation, labels.
default_protection_level string "SOFTWARE" No Protection level for keys that don’t override it (SOFTWARE/HSM/EXTERNAL).
default_rotation_period string "7776000s" No Default symmetric-key rotation period (seconds suffix), or null to disable.
key_iam_encrypters map(list(string)) {} No Per-key members granted cryptoKeyEncrypterDecrypter (use service agents for CMEK).
labels map(string) {} No Labels applied to every crypto key.

Outputs

Name Description
key_ring_id Fully-qualified key ring ID (projects/…/keyRings/…).
key_ring_name Short name of the key ring.
location Location of the key ring.
crypto_key_ids Map of key name → fully-qualified crypto key ID, ready to use as kms_key_name.
crypto_keys Map of key name → { id, purpose, protection_level } for downstream logic.

Enterprise scenario

A regulated fintech runs a central security-prod project that owns all encryption keys, while line-of-business teams run their own workload projects. The platform team deploys this module once per region with an HSM-backed key ring and a 90-day rotation policy, then uses key_iam_encrypters to grant each downstream project’s Cloud Storage and BigQuery service agents cryptoKeyEncrypterDecrypter on only the keys they need — enforcing key segregation and a clean audit trail. Because both the key ring and crypto keys carry prevent_destroy, a fat-fingered terraform destroy in a workload pipeline can never orphan the keys that protect production data.

Best practices

TerraformGCPCloud KMSModuleIaC
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