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
- You need CMEK for Cloud Storage buckets, BigQuery datasets, Compute disks, Cloud SQL, or Pub/Sub topics and want one key ring per environment with named purpose-built keys.
- Compliance (PCI-DSS, HIPAA, FedRAMP) mandates automatic key rotation and an auditable, version-pinned definition of every key.
- You want HSM-backed (FIPS 140-2 Level 3) keys for regulated workloads without manually picking supported regions each time.
- You need to grant cross-project service agents (e.g., the Cloud Storage service agent in project B) decrypt rights on a key owned by a central security project.
- You are standardising on a platform-team pattern where application teams request a key by name and never touch IAM or rotation policy themselves.
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 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/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
- Treat key rings and keys as permanent. They cannot be deleted in GCP, so enforce naming conventions up front and keep
prevent_destroy = true. Useterraform state rm(not destroy) if you ever need to stop managing one. - Rotate symmetric keys automatically. Set
rotation_period(90 days is a common compliance baseline; the GCP minimum is86400s). Rotation only applies toENCRYPT_DECRYPTkeys — this module nulls it out for asymmetric and MAC keys automatically. - Choose protection level deliberately.
HSM(FIPS 140-2 Level 3) costs more per key version and is region-limited; use it for regulated data and keep low-sensitivity keys onSOFTWAREto control cost. - Grant least privilege via service agents, not broad roles. Bind
cryptoKeyEncrypterDecrypterto the specific service agent and specific key (as this module does) rather than grantingroles/cloudkms.adminproject-wide. - Co-locate keys with the data they protect. A key ring’s
locationmust match (or be a compatible multi-region of) the resource using it — ausbucket can’t use anasia-south1key. Align regions to avoid latency and policy conflicts. - Track keys with labels and never embed key material. Cloud KMS never exposes raw key bytes; reference keys only by their resource ID output, and use
labels(environment, owner, data-classification) for cost attribution and governance queries.