Home / Security

GCP Secret Manager Terraform Module with Automatic Rotation — Production Guide | 2026

GCP Secret Manager Terraform Module with Automatic Rotation — Production Guide | 2026

A Terraform module for GCP Secret Manager can enforce automatic secret rotation by default: set a `rotation` block with `rotation_period` (e.g., 7776000s = 90 days) on every `google_secret_manager_secret`, attach a Pub/Sub topic for rotation notifications, and let a Cloud Function subscriber perform the actual credential rollover — so no secret is ever created without an expiry schedule.

By Mateusz Chmielewski · Aug 4, 2026 · 22 min read

What Is Automatic Secret Rotation in GCP Secret Manager?

Google Cloud Secret Manager is a managed vault for API keys, passwords, and certificates. Its rotation schedule feature lets you declare a rotation period on a secret; at each interval Secret Manager publishes a `SECRET_ROTATE` event to a Pub/Sub topic. A subscriber — typically a Cloud Function or Cloud Run job — then generates a new credential, adds it as a new secret version, and disables the old one. Wrapping this in a Terraform module with a mandatory default rotation period guarantees every secret in your organization rotates automatically after X days.

Think of a hotel that re-keys every room lock on a fixed schedule. The front desk (Secret Manager) keeps a calendar and sends a reminder (Pub/Sub message) when a lock is due. The locksmith (Cloud Function) actually cuts the new key. If you never put rooms on the calendar, locks stay unchanged for years — the module's job is to make the calendar entry mandatory by default.

ConceptExplanationWhen to use
Secret VersionAn immutable snapshot of a secret's payload; versions are added, enabled, disabled, or destroyed.Rolling credentials: add a new version, point clients at it, then disable the old one.
Rotation PeriodThe interval (e.g., 7776000s = 90 days) after which Secret Manager flags a secret for rotation.Enforcing compliance policies such as PCI-DSS or internal 30/90-day credential lifetimes.
Rotation NotificationA `SECRET_ROTATE` message published to a Pub/Sub topic attached to the secret.Triggering automation that generates and stores the next credential version.
Replication PolicyAutomatic (Google-managed) or user-managed (explicit regions + optional CMEK) storage layout.Choosing automatic for simplicity, or user-managed for data residency and CMEK requirements.
Version AliasesNamed pointers like `latest` that resolve to a specific version number.Keeping workloads pinned to the newest good version without hard-coding numbers.

Why Enforce Rotation by Default in a Terraform Module?

In most teams, secrets are created once and never rotated. Rotation is left to human runbooks, which means database passwords and API keys quietly live for years — dramatically increasing blast radius when a credential leaks through logs, CI artifacts, or a compromised laptop.

Encoding rotation as a non-optional module default flips the model: every `google_secret_manager_secret` created through the module gets a `rotation` block, a Pub/Sub notification topic, and a rotator function wired in automatically. Teams must explicitly opt out (and document why) instead of opting in. Combine this with [VPC Service Controls perimeters](/tutorial/terraform-vpc-service-controls-custom-module-guide) to stop exfiltration of the secrets themselves, and [IAM Recommender least-privilege fixes](/tutorial/fix-overprivileged-iam-policy-analyzer-recommender) to keep accessor roles tight.

FeaturethisServicealtAaltB
Rotation EnforcementDefault-on via module variable (opt-out only)Manual runbooks (often skipped)Ad-hoc cron scripts per secret
Rollover ExecutionPub/Sub → Cloud Function, fully automatedEngineer runs gcloud by handCustom schedulers, inconsistent logic
AuditabilityTerraform state + audit logs + version historySpreadsheets and tribal knowledgeScript logs scattered across projects
Consistency Across TeamsOne module, one policy (e.g., 90 days)Varies by team and by memoryPer-repo reinvention of rotation logic

Prerequisites

  • GCP project with active billing
  • gcloud CLI v450.0+ installed and authenticated
  • Terraform CLI v1.5.0+ installed locally or in CI/CD runner
  • Secret Manager Admin role (`roles/secretmanager.admin`) for initial setup
  • Pub/Sub Admin (`roles/pubsub.admin`) and Cloud Functions Developer (`roles/cloudfunctions.developer`) if deploying the rotator

Step-by-Step Guide

Step 1: Enable Required APIs

Activating Secret Manager, Pub/Sub, and Cloud Functions APIs in the target project. Rotation depends on event notifications, so both Secret Manager and Pub/Sub must be active before any secret with a rotation schedule can be created.

gcloud services enable secretmanager.googleapis.com \
  pubsub.googleapis.com \
  cloudfunctions.googleapis.com \
  cloudbuild.googleapis.com \
  --project=my-project-id

Step 2: Define Module Inputs with a Mandatory Rotation Default (variables.tf)

Declaring module variables where `rotation_period` defaults to 90 days and can only be disabled by an explicit, auditable flag. The default is the security control: any consumer who forgets rotation settings still gets automatic rotation after X days.

variable "project_id" {
  description = "GCP project that hosts the secret"
  type        = string
}

variable "secret_id" {
  description = "Name of the secret in Secret Manager"
  type        = string
}

variable "rotation_period" {
  description = "Interval between rotations, in seconds (90 days by default)"
  type        = string
  default     = "7776000s" # 90 days
}

variable "rotation_enabled" {
  description = "Set false ONLY with a documented exception (e.g., third-party key you do not control)"
  type        = bool
  default     = true
}

variable "deploy_rotator" {
  description = "Deploy the Cloud Function that performs the actual credential rollover"
  type        = bool
  default     = true
}

variable "replication_locations" {
  description = "Empty list = automatic replication; otherwise user-managed regional replicas"
  type        = list(string)
  default     = []
}

variable "labels" {
  description = "Labels for cost allocation and policy grouping"
  type        = map(string)
  default     = {}
}

Step 3: Build the Secret with Rotation Schedule and Pub/Sub Topic (main.tf)

Creating the notification topic, the secret with a dynamic `rotation` block, and the Pub/Sub publisher IAM binding that Secret Manager requires. Without the topic IAM binding for the Secret Manager service agent, rotation events are silently never published — the schedule exists but nothing fires.

locals {
  rotation_enabled = var.rotation_enabled
  topic_name       = "${var.secret_id}-rotation"
}

resource "google_pubsub_topic" "rotation" {
  count   = local.rotation_enabled ? 1 : 0
  project = var.project_id
  name    = local.topic_name
  labels  = var.labels
}

# Secret Manager publishes rotation events through its service agent,
# which MUST be granted publisher rights on the topic.
resource "google_pubsub_topic_iam_member" "secret_manager_publisher" {
  count   = local.rotation_enabled ? 1 : 0
  project = var.project_id
  topic   = google_pubsub_topic.rotation[0].name
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:service-${data.google_project.current.number}@gcp-sa-secretmanager.iam.gserviceaccount.com"
}

resource "google_secret_manager_secret" "this" {
  project   = var.project_id
  secret_id = var.secret_id
  labels    = var.labels

  replication {
    dynamic "auto" {
      for_each = length(var.replication_locations) == 0 ? [1] : []
      content {}
    }
    dynamic "user_managed" {
      for_each = length(var.replication_locations) > 0 ? [1] : []
      content {
        dynamic "replicas" {
          for_each = var.replication_locations
          content {
            location = replicas.value
          }
        }
      }
    }
  }

  dynamic "rotation" {
    for_each = local.rotation_enabled ? [1] : []
    content {
      rotation_period = var.rotation_period
    }
  }

  dynamic "topics" {
    for_each = local.rotation_enabled ? [google_pubsub_topic.rotation[0].name] : []
    content {
      name = "projects/${var.project_id}/topics/${topics.value}"
    }
  }

  depends_on = [google_pubsub_topic_iam_member.secret_manager_publisher]
}

data "google_project" "current" {
  project_id = var.project_id
}

Step 4: Deploy the Rotator Cloud Function (rotator.tf)

Adding a Pub/Sub-triggered Cloud Function (2nd gen) that receives SECRET_ROTATE events and creates a new secret version. Secret Manager schedules and notifies — it does not rotate credentials by itself. The rotator closes the loop.

resource "google_service_account" "rotator" {
  count        = local.rotation_enabled && var.deploy_rotator ? 1 : 0
  project      = var.project_id
  account_id   = "${var.secret_id}-rotator"
  display_name = "Rotator for ${var.secret_id}"
}

resource "google_secret_manager_secret_iam_member" "rotator_version_manager" {
  count     = local.rotation_enabled && var.deploy_rotator ? 1 : 0
  project   = var.project_id
  secret_id = google_secret_manager_secret.this.secret_id
  role      = "roles/secretmanager.secretVersionManager"
  member    = "serviceAccount:${google_service_account.rotator[0].email}"
}

resource "google_pubsub_subscription" "rotator" {
  count   = local.rotation_enabled && var.deploy_rotator ? 1 : 0
  project = var.project_id
  name    = "${local.topic_name}-sub"
  topic   = google_pubsub_topic.rotation[0].name

  ack_deadline_seconds       = 60
  message_retention_duration = "86400s"

  expiration_policy {
    ttl = "" # never expire
  }
}

resource "google_cloudfunctions2_function" "rotator" {
  count    = local.rotation_enabled && var.deploy_rotator ? 1 : 0
  project  = var.project_id
  name     = "${var.secret_id}-rotator"
  location = "europe-central2"

  build_config {
    runtime     = "python312"
    entry_point = "rotate_secret"
    source {
      storage_source {
        bucket = google_storage_bucket.rotator_source[0].name
        object = google_storage_bucket_object.rotator_zip[0].name
      }
    }
  }

  service_config {
    service_account_email = google_service_account.rotator[0].email
    max_instance_count    = 2
  }

  event_trigger {
    trigger_region = "europe-central2"
    event_type     = "google.cloud.pubsub.topic.v1.messagePublished"
    pubsub_topic   = google_pubsub_topic.rotation[0].id
    retry_policy   = "RETRY_POLICY_RETRY"
  }
}

Step 5: Write the Rotator Logic (main.py)

The Python entry point that generates a new credential, adds it as a version, and disables the previous one. This is where provider-specific rotation logic lives (e.g., generating a new DB password and updating the database user).

import base64
import json
import secrets

from google.cloud import secretmanager


def rotate_secret(event, context):
    \"\"\"Triggered by a SECRET_ROTATE Pub/Sub message.\"\"\"
    message = json.loads(base64.b64decode(event["data"]).decode())
    if message.get("eventType") != "SECRET_ROTATE":
        return  # ignore version events

    secret_name = message["secretId"]  # projects/*/secrets/*/versions/...
    parent = secret_name.rsplit("/versions/", 1)[0]
    client = secretmanager.SecretManagerServiceClient()

    # 1. Generate the new credential (adapt per system: DB user, API key, ...)
    new_value = secrets.token_urlsafe(32)

    # 2. TODO: apply the credential to the target system here
    #    e.g., ALTER USER ... IDENTIFIED BY for Cloud SQL

    # 3. Store it as the new active version
    client.add_secret_version(
        request={
            "parent": parent,
            "payload": {"data": new_value.encode("utf-8")},
        }
    )

Step 6: Expose Module Outputs (outputs.tf)

Returning secret resource name, topic, and effective rotation period for downstream pipelines and documentation. Consumers and audit tooling can read the effective policy without parsing module internals.

output "secret_name" {
  description = "Full resource name of the secret"
  value       = google_secret_manager_secret.this.name
}

output "rotation_period" {
  description = "Effective rotation period (null when opted out)"
  value       = local.rotation_enabled ? var.rotation_period : null
}

output "rotation_topic" {
  description = "Pub/Sub topic receiving SECRET_ROTATE events"
  value       = local.rotation_enabled ? google_pubsub_topic.rotation[0].name : null
}

output "rotator_service_account" {
  description = "Service account running the rotation function"
  value       = local.rotation_enabled && var.deploy_rotator ? google_service_account.rotator[0].email : null
}

Step 7: Instantiate the Module and Apply

Calling the module from the root configuration for a database credential, using all secure defaults. Demonstrates that the consumer needs zero rotation configuration — automatic rotation after 90 days simply happens.

module "cloudsql_app_password" {
  source = "./modules/terraform-google-secret-rotation"

  project_id = "my-project-id"
  secret_id  = "cloudsql-app-password"

  labels = {
    env  = "prod"
    team = "payments"
  }
}

# Exception example: vendor-managed key we cannot rotate.
# The opt-out is explicit and reviewable in the plan diff.
module "third_party_api_key" {
  source = "./modules/terraform-google-secret-rotation"

  project_id       = "my-project-id"
  secret_id        = "vendor-api-key"
  rotation_enabled = false
}

Verification & Health Check

Best Practices

  • Default Rotation On, Never Off
  • Grant the Rotator Version Manager, Not Accessor
  • Rotate the Target System, Not Just the Secret
  • Set Retry and Dead-Letter Policies on the Subscription
  • Use Version Aliases for Client References

Common Mistakes

  • {"errorCode":"FAILED_PRECONDITION","symptoms":"Apply fails with `Topic ... does not have permission to be used for rotation notifications` when adding a `topics` block.","rootCause":"The Secret Manager service agent lacks Pub/Sub publisher rights on the rotation topic.","fixCommand":"gcloud pubsub topics add-iam-policy-binding cloudsql-app-password-rotation --member='serviceAccount:service-PROJECT_NUMBER@gcp-sa-secretmanager.iam.gserviceaccount.com' --role='roles/pubsub.publisher' --project=my-project-id","code":"resource \"google_pubsub_topic_iam_member\" \"secret_manager_publisher\" {\n project = var.project_id\n topic = google_pubsub_topic.rotation[0].name\n role = \"roles/pubsub.publisher\"\n member = \"serviceAccount:service-${data.google_project.current.number}@gcp-sa-secretmanager.iam.gserviceaccount.com\"\n}\n","language":"hcl","filename":"main.tf","prevention":"Keep the topic IAM binding and a `depends_on` inside the module so the ordering requirement can never be forgotten."}
  • {"errorCode":"PERMISSION_DENIED","symptoms":"Cloud Function logs show `Permission 'secretmanager.versions.add' denied` during a rotation run.","rootCause":"The rotator service account was granted `secretAccessor` (read-only) instead of `secretVersionManager`.","fixCommand":"gcloud secrets add-iam-policy-binding cloudsql-app-password --member='serviceAccount:cloudsql-app-password-rotator@my-project-id.iam.gserviceaccount.com' --role='roles/secretmanager.secretVersionManager' --project=my-project-id","code":"resource \"google_secret_manager_secret_iam_member\" \"rotator_version_manager\" {\n project = var.project_id\n secret_id = google_secret_manager_secret.this.secret_id\n role = \"roles/secretmanager.secretVersionManager\"\n member = \"serviceAccount:${google_service_account.rotator[0].email}\"\n}\n","language":"hcl","filename":"rotator.tf","prevention":"Wire the rotator IAM binding inside the module and unit-test that the plan contains exactly one role grant."}
  • {"errorCode":"INVALID_ARGUMENT","symptoms":"Apply fails with `rotation_period must be at least 3600s` or a malformed duration error.","rootCause":"The period was passed as a plain number or below the one-hour minimum enforced by the API.","fixCommand":"terraform plan -var='rotation_period=2592000s'","code":"variable \"rotation_period\" {\n type = string\n default = \"7776000s\"\n\n validation {\n condition = can(regex(\"^[0-9]+s$\", var.rotation_period)) && tonumber(trimsuffix(var.rotation_period, \"s\")) >= 3600\n error_message = \"Rotation period must be a duration string like '2592000s' and at least 3600s (1 hour).\"\n }\n}\n","language":"hcl","filename":"variables.tf","prevention":"Validate duration format and minimums in the variable block so bad values fail at plan time, not at the API."}
  • {"errorCode":"ROTATION_SILENTLY_SKIPPED","symptoms":"`nextRotationTime` keeps moving forward, but no new secret version ever appears.","rootCause":"The Pub/Sub subscription or Cloud Function trigger was deleted or misconfigured, so SECRET_ROTATE events have no consumer.","fixCommand":"gcloud pubsub subscriptions describe cloudsql-app-password-rotation-sub --project=my-project-id","code":"resource \"google_pubsub_subscription\" \"rotator\" {\n name = \"${local.topic_name}-sub\"\n topic = google_pubsub_topic.rotation[0].name\n\n expiration_policy {\n ttl = \"\" # REQUIRED: default 31-day expiry silently deletes idle subscriptions\n }\n}\n","language":"hcl","filename":"rotator.tf","prevention":"Always set an empty `expiration_policy.ttl` on rotation subscriptions and alert on undelivered message backlog."}

Cost Analysis

Featuremetriccost1kcost10kcost100kcost1m
Active Secret Versions (300 × $0.06)$18.00$18.00$18.00$18.00
Access Operations (first 10K free, $0.03 per 10K)$0.00$0.00$0.27$2.97
Pub/Sub Notifications (~150 msgs/mo)$0.00$0.00$0.00$0.00
Rotator Cloud Function (invocations within free tier)$0.00$0.00$0.00$0.00

References

Browse all tutorials