IaC GCP

Terraform Module: GCP Secret Manager — One Secret, Versioned, Replicated, and Access-Scoped

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

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 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/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

TerraformGCPSecret ManagerModuleIaC
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