Quick take — A reusable hashicorp/google ~> 5.0 Terraform module for google_secret_manager_secret: user-managed CMEK replication, rotation, version aliases, and least-privilege IAM bindings for accessors. 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 "secret_manager" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-secret-manager?ref=v1.0.0"
project_id = "..." # GCP project ID that owns the secret.
secret_id = "..." # Secret ID within the project; validated against `^[A-Za…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
GCP Secret Manager is a regional, IAM-governed store for sensitive blobs — database passwords, API keys, TLS private keys, OAuth client secrets — that you do not want sitting in a tfvars file or a CI variable. Each google_secret_manager_secret is a named container; the actual bytes live in immutable versions that you add, disable, or destroy independently, so rotation is “add version, repoint consumers, disable old” rather than a destructive overwrite. Secret Manager gives you per-secret and per-version IAM, optional CMEK encryption, automatic-vs-user-managed replication, and a built-in rotation timer that pokes a Pub/Sub topic on a schedule.
Wrapping it in a module matters because a correct secret is more than the container. In production you almost always want the same opinionated bundle every time: a defined replication policy (you cannot change automatic vs user_managed after creation — it forces a destroy/recreate), CMEK keys wired per replica location, labels for cost and ownership attribution, rotation + Pub/Sub topic, and least-privilege roles/secretmanager.secretAccessor bindings for exactly the service accounts that need it. Hand-rolling that per secret invites drift — one team forgets the accessor binding, another hardcodes a region, a third leaves the plaintext in a version resource committed to git. This module makes the secret container and its guardrails the unit of reuse, while deliberately keeping the sensitive payload out of Terraform state by default.
When to use it
- You provision app credentials (DB passwords, third-party API keys, signing keys) and want them created and access-controlled as code, not clicked in the console.
- You need CMEK (customer-managed Cloud KMS keys) on secrets for compliance, and want the key-to-location mapping enforced consistently.
- You run multi-region workloads and must pin secret replication to specific regions (data residency) instead of Google’s automatic global replication.
- You want secret rotation reminders delivered to Pub/Sub so a Cloud Function / Cloud Run job can rotate and add a new version.
- You want least-privilege accessor IAM (
secretmanager.secretAccessor) granted to named service accounts at the secret level, reviewed in PRs. - Skip it for truly ephemeral, non-sensitive config — Secret Manager has a per-secret-version access cost and API quota; plain env vars or
google_runtime_configfit better there.
Module structure
terraform-module-gcp-secret-manager/
├── versions.tf # provider + version pins
├── main.tf # google_secret_manager_secret + IAM + optional first version
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name + secret_id + version refs
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Normalise accessor members to fully-qualified IAM identities.
accessor_members = toset(var.accessor_members)
# When user_managed replication is requested, build one replica block per
# location, attaching a CMEK key only for locations present in kms_key_by_location.
user_managed_replicas = [
for loc in var.replica_locations : {
location = loc
kms_key = lookup(var.kms_key_by_location, loc, null)
}
]
}
resource "google_secret_manager_secret" "this" {
project = var.project_id
secret_id = var.secret_id
labels = merge(
{
managed-by = "terraform"
module = "gcp-secret-manager"
},
var.labels,
)
# Free-form annotations (e.g. owning team, ticket, data-classification).
annotations = var.annotations
# Replication policy is IMMUTABLE: automatic OR user_managed, never both,
# and cannot be switched without recreating the secret.
replication {
dynamic "auto" {
for_each = var.replication_type == "automatic" ? [1] : []
content {
dynamic "customer_managed_encryption" {
for_each = var.automatic_kms_key == null ? [] : [var.automatic_kms_key]
content {
kms_key_name = customer_managed_encryption.value
}
}
}
}
dynamic "user_managed" {
for_each = var.replication_type == "user_managed" ? [1] : []
content {
dynamic "replicas" {
for_each = local.user_managed_replicas
content {
location = replicas.value.location
dynamic "customer_managed_encryption" {
for_each = replicas.value.kms_key == null ? [] : [replicas.value.kms_key]
content {
kms_key_name = customer_managed_encryption.value
}
}
}
}
}
}
}
# Optional scheduled rotation: requires a Pub/Sub topic in topics{}.
dynamic "rotation" {
for_each = var.rotation_period == null ? [] : [1]
content {
rotation_period = var.rotation_period
next_rotation_time = var.next_rotation_time
}
}
# Pub/Sub topics that receive control-plane events (incl. rotation reminders).
dynamic "topics" {
for_each = toset(var.notification_topics)
content {
name = topics.value
}
}
# Optional TTL or absolute expiry for the secret container.
expire_time = var.expire_time
ttl = var.ttl
# Behaviour for versions when the secret is destroyed.
version_destroy_ttl = var.version_destroy_ttl
lifecycle {
precondition {
condition = var.replication_type == "automatic" ? length(var.replica_locations) == 0 : length(var.replica_locations) > 0
error_message = "Set replica_locations ONLY for user_managed replication, and it must be non-empty there."
}
precondition {
condition = var.rotation_period == null || length(var.notification_topics) > 0
error_message = "rotation_period requires at least one notification_topics entry to deliver rotation reminders."
}
}
}
# Optionally seed the FIRST secret version. Off by default so plaintext does
# not have to live in Terraform state; prefer adding versions out-of-band.
resource "google_secret_manager_secret_version" "initial" {
count = var.initial_secret_data == null ? 0 : 1
secret = google_secret_manager_secret.this.id
secret_data = var.initial_secret_data
enabled = true
# Avoid destroying the seeded version on destroy if other versions exist.
deletion_policy = var.version_deletion_policy
}
# Least-privilege accessor IAM at the secret level.
resource "google_secret_manager_secret_iam_member" "accessors" {
for_each = local.accessor_members
project = google_secret_manager_secret.this.project
secret_id = google_secret_manager_secret.this.secret_id
role = "roles/secretmanager.secretAccessor"
member = each.value
}
variables.tf
variable "project_id" {
type = string
description = "GCP project ID that owns the secret."
}
variable "secret_id" {
type = string
description = "Secret ID (the resource name within the project). Lowercase letters, digits, hyphens, underscores; up to 255 chars."
validation {
condition = can(regex("^[A-Za-z0-9_-]{1,255}$", var.secret_id))
error_message = "secret_id must match ^[A-Za-z0-9_-]{1,255}$ (letters, digits, '-', '_')."
}
}
variable "replication_type" {
type = string
default = "automatic"
description = "Replication policy: 'automatic' (Google-managed, global) or 'user_managed' (you pick locations). IMMUTABLE after create."
validation {
condition = contains(["automatic", "user_managed"], var.replication_type)
error_message = "replication_type must be 'automatic' or 'user_managed'."
}
}
variable "replica_locations" {
type = list(string)
default = []
description = "Regions for user_managed replication, e.g. [\"asia-south1\", \"asia-south2\"]. Leave empty for automatic replication."
}
variable "automatic_kms_key" {
type = string
default = null
description = "CMEK Cloud KMS key for automatic replication, e.g. projects/p/locations/global/keyRings/r/cryptoKeys/k. Null = Google-managed key."
}
variable "kms_key_by_location" {
type = map(string)
default = {}
description = "Map of replica location -> CMEK Cloud KMS key for user_managed replication. Locations not present use Google-managed keys."
}
variable "labels" {
type = map(string)
default = {}
description = "Labels merged onto the secret for cost/ownership attribution."
}
variable "annotations" {
type = map(string)
default = {}
description = "Free-form annotations on the secret (e.g. owner, data-classification, ticket)."
}
variable "rotation_period" {
type = string
default = null
description = "Rotation interval as a duration string in seconds, e.g. \"7776000s\" (90 days). Min 3600s. Null disables rotation."
validation {
condition = var.rotation_period == null || can(regex("^[0-9]+s$", var.rotation_period))
error_message = "rotation_period must be a seconds duration string like \"7776000s\", or null."
}
}
variable "next_rotation_time" {
type = string
default = null
description = "RFC3339 timestamp of the first rotation, e.g. \"2026-09-01T00:00:00Z\". Required by the API when rotation_period is set."
}
variable "notification_topics" {
type = list(string)
default = []
description = "Pub/Sub topic resource names for control-plane events, e.g. projects/p/topics/secret-rotations. Required when rotation is enabled."
}
variable "expire_time" {
type = string
default = null
description = "Absolute RFC3339 expiry for the secret container. Mutually exclusive with ttl."
}
variable "ttl" {
type = string
default = null
description = "Relative TTL duration (seconds) after which the secret expires, e.g. \"2592000s\". Mutually exclusive with expire_time."
}
variable "version_destroy_ttl" {
type = string
default = null
description = "Delay before destroyed versions are permanently deleted, e.g. \"86400s\" (24h), enabling recovery. Null = immediate."
}
variable "initial_secret_data" {
type = string
default = null
sensitive = true
description = "Optional plaintext for the FIRST version. Off by default to keep payloads out of state; prefer adding versions out-of-band."
}
variable "version_deletion_policy" {
type = string
default = "DELETE"
description = "Deletion policy for the seeded initial version: DELETE, DISABLE, or ABANDON."
validation {
condition = contains(["DELETE", "DISABLE", "ABANDON"], var.version_deletion_policy)
error_message = "version_deletion_policy must be one of DELETE, DISABLE, ABANDON."
}
}
variable "accessor_members" {
type = list(string)
default = []
description = "IAM members granted roles/secretmanager.secretAccessor, e.g. [\"serviceAccount:app@p.iam.gserviceaccount.com\"]."
}
outputs.tf
output "id" {
description = "Fully-qualified secret resource ID: projects/<num>/secrets/<secret_id>."
value = google_secret_manager_secret.this.id
}
output "name" {
description = "Resource name of the secret (same form as id)."
value = google_secret_manager_secret.this.name
}
output "secret_id" {
description = "Short secret_id used by accessors when building version references."
value = google_secret_manager_secret.this.secret_id
}
output "project" {
description = "Project that owns the secret (resolved project number/ID)."
value = google_secret_manager_secret.this.project
}
output "initial_version_id" {
description = "Resource ID of the seeded initial version, or null when none was created."
value = try(google_secret_manager_secret_version.initial[0].id, null)
}
output "latest_version_reference" {
description = "Convenience reference string to the latest version for consumers: <secret_id>/versions/latest."
value = "${google_secret_manager_secret.this.secret_id}/versions/latest"
}
How to use it
# A CMEK Cloud KMS key in the same region as the replica.
resource "google_kms_crypto_key" "secrets" {
name = "secrets-asia-south1"
key_ring = google_kms_key_ring.secrets.id
}
# Pub/Sub topic that receives rotation reminders.
resource "google_pubsub_topic" "secret_rotations" {
project = "kloudvin-prod"
name = "secret-rotations"
}
module "db_password" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-secret-manager?ref=v1.0.0"
project_id = "kloudvin-prod"
secret_id = "orders-db-app-password"
# Pin replication to Indian regions for data residency, with CMEK.
replication_type = "user_managed"
replica_locations = ["asia-south1", "asia-south2"]
kms_key_by_location = {
"asia-south1" = google_kms_crypto_key.secrets.id
}
# Rotate every 90 days and notify the rotation pipeline.
rotation_period = "7776000s"
next_rotation_time = "2026-09-01T00:00:00Z"
notification_topics = [google_pubsub_topic.secret_rotations.id]
# Recover destroyed versions for 24h before permanent deletion.
version_destroy_ttl = "86400s"
# Only the orders API service account may read it.
accessor_members = [
"serviceAccount:orders-api@kloudvin-prod.iam.gserviceaccount.com",
]
labels = {
app = "orders"
environment = "prod"
}
annotations = {
owner = "platform-team"
data-classification = "restricted"
}
}
# Downstream: mount the secret's latest version into a Cloud Run service.
resource "google_cloud_run_v2_service" "orders_api" {
name = "orders-api"
location = "asia-south1"
template {
service_account = "orders-api@kloudvin-prod.iam.gserviceaccount.com"
containers {
image = "asia-south1-docker.pkg.dev/kloudvin-prod/apps/orders-api:latest"
env {
name = "DB_PASSWORD"
value_source {
secret_key_ref {
# Consume the module output rather than hardcoding the secret name.
secret = module.db_password.secret_id
version = "latest"
}
}
}
}
}
}
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/secret_manager/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-secret-manager?ref=v1.0.0"
}
inputs = {
project_id = "..."
secret_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/secret_manager && 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 owns the secret. |
secret_id |
string |
— | Yes | Secret ID within the project; validated against ^[A-Za-z0-9_-]{1,255}$. |
replication_type |
string |
"automatic" |
No | automatic (global) or user_managed. Immutable after create. |
replica_locations |
list(string) |
[] |
No | Regions for user_managed replication; empty for automatic. |
automatic_kms_key |
string |
null |
No | CMEK key for automatic replication; null = Google-managed. |
kms_key_by_location |
map(string) |
{} |
No | Per-location CMEK keys for user_managed replication. |
labels |
map(string) |
{} |
No | Labels merged onto the secret for attribution. |
annotations |
map(string) |
{} |
No | Free-form annotations (owner, classification, ticket). |
rotation_period |
string |
null |
No | Rotation interval as seconds duration (e.g. "7776000s"); null disables. |
next_rotation_time |
string |
null |
No | RFC3339 first-rotation timestamp; required by the API when rotation is set. |
notification_topics |
list(string) |
[] |
No | Pub/Sub topic resource names; required when rotation is enabled. |
expire_time |
string |
null |
No | Absolute RFC3339 container expiry; mutually exclusive with ttl. |
ttl |
string |
null |
No | Relative TTL duration (seconds); mutually exclusive with expire_time. |
version_destroy_ttl |
string |
null |
No | Delay before destroyed versions are purged, enabling recovery. |
initial_secret_data |
string (sensitive) |
null |
No | Plaintext for the first version; off by default to keep payloads out of state. |
version_deletion_policy |
string |
"DELETE" |
No | Deletion policy for the seeded version: DELETE, DISABLE, ABANDON. |
accessor_members |
list(string) |
[] |
No | Members granted roles/secretmanager.secretAccessor. |
Outputs
| Name | Description |
|---|---|
id |
Fully-qualified secret resource ID: projects/<num>/secrets/<secret_id>. |
name |
Resource name of the secret. |
secret_id |
Short secret_id used by accessors to build version references. |
project |
Project that owns the secret. |
initial_version_id |
Resource ID of the seeded initial version, or null when none was created. |
latest_version_reference |
Convenience string <secret_id>/versions/latest for consumers. |
Enterprise scenario
A fintech platform team runs a regulated payments service in asia-south1 and must keep all credential material inside Indian regions under customer-managed keys. They instantiate this module once per credential (issuer API key, DB password, HMAC signing key) with replication_type = "user_managed", replica_locations = ["asia-south1", "asia-south2"], and a per-region Cloud KMS CMEK, while accessor_members grants read access to exactly the payment service’s runtime service account — nothing platform-wide. Rotation is set to 90 days with reminders fanned out to a secret-rotations Pub/Sub topic, where a Cloud Run rotation job generates new credentials, calls the upstream provider, and adds a new secret version, so the auditors see both residency and rotation enforced as reviewed Terraform.
Best practices
- Keep plaintext out of state. Leave
initial_secret_dataunset and add versions viagcloud secrets versions add, a CI step, or the rotation job. If you must seed it, mark the varsensitive(it already is) and treat the state backend as a secret store with restricted access. - Grant
secretAccessor, notadmin, and per-secret. Useaccessor_membersto bind read access at the individual secret level for named service accounts; never grant project-wideroles/secretmanager.adminto applications. Audit accessor changes in PRs. - Choose replication deliberately — it is immutable.
user_managedwith explicitreplica_locationsgives data residency and predictable failure domains; switching to/fromautomaticforces a destroy/recreate of the secret, so decide up front per workload. - Use CMEK where compliance requires it, and match key location to replica. With
user_managedreplication, the KMS key inkms_key_by_locationmust live in the same location as its replica, and Secret Manager’s service agent needsroles/cloudkms.cryptoKeyEncrypterDecrypteron the key. - Rotate, and make rotation observable. Set
rotation_periodplusnotification_topicsso reminders reach a rotation pipeline; pair withversion_destroy_ttl(e.g."86400s") so an accidental version destroy is recoverable rather than instant. - Control cost with version hygiene and labels. Billing scales with active secret versions and access operations, so disable/destroy superseded versions instead of accumulating them, cache reads in long-lived processes rather than fetching per request, and apply
labelsfor per-app/environment cost attribution.