IaC GCP

Terraform Module: GCP Pub/Sub — Topic, Subscriptions, DLQ and Retention in One Block

Quick take — A reusable hashicorp/google ~> 5.0 module for google_pubsub_topic and google_pubsub_subscription: message retention, dead-letter topics, retry policy, ordering, exactly-once delivery, push/pull and CMEK. 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 "pubsub" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-pubsub?ref=v1.0.0"

  project_id = "..."  # GCP project ID hosting the topic and subscriptions.
  topic_name = "..."  # Topic name; 3-255 chars, starts with a letter, not `goo…
}

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

What this module is

Pub/Sub is GCP’s fully managed, global, asynchronous messaging service. Publishers send messages to a topic; Pub/Sub durably stores each message and fans it out to every subscription attached to that topic, where consumers either pull messages (Pull / StreamingPull) or have them delivered to an HTTPS endpoint (Push). It decouples producers from consumers, absorbs traffic spikes, and guarantees at-least-once delivery — which means the interesting engineering is almost never the topic itself, but the subscription settings that govern delivery semantics.

A topic on its own is one line of HCL. A correct Pub/Sub deployment is not. In production you need the subscription’s acknowledgement deadline tuned to how long the consumer actually takes, an exponential retry policy so transient failures back off instead of hammering, a dead-letter topic so poison messages stop being redelivered forever and land somewhere you can inspect them, a message retention duration so you can replay or seek-to-time after an incident, and frequently message ordering (per ordering key) or exactly-once delivery for financial and inventory flows. Push subscriptions add an OIDC service-account token so your endpoint can authenticate the caller. Hand-writing all of that per topic means every team re-derives the dead-letter wiring — and the IAM grants the DLQ needs — and gets it subtly wrong.

This module wraps google_pubsub_topic and google_pubsub_subscription into one opinionated, variable-driven block. It creates the topic (optionally CMEK-encrypted with a schema attached), creates any number of subscriptions from a single map, optionally provisions a dead-letter topic plus the exact IAM bindings the Pub/Sub service agent needs to publish failures and acknowledge originals, and exposes topic and subscription IDs as outputs so publishers, push targets, and Cloud Run consumers can wire straight in.

When to use it

Skip it for synchronous request/response (use Cloud Run or an HTTP API directly), for sub-millisecond in-memory fan-out (use Memorystore/Redis pub-sub), or for high-throughput Kafka-compatible streaming where you specifically want the partition model of Pub/Sub Lite or Managed Kafka instead.

Module structure

terraform-module-gcp-pubsub/
├── versions.tf      # provider + required_version pins
├── main.tf          # topic, optional DLQ topic, subscriptions, DLQ IAM
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # topic id/name, subscription ids, dlq topic id

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

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

locals {
  # The Pub/Sub service agent. It needs roles/pubsub.publisher on the
  # dead-letter topic and roles/pubsub.subscriber on the source subscription
  # so Pub/Sub can forward failures and ack the originals.
  pubsub_service_agent = "serviceAccount:service-${data.google_project.this.number}@gcp-sa-pubsub.iam.gserviceaccount.com"

  # Only provision a DLQ topic if dead-lettering is enabled and no external
  # dead-letter topic was supplied by the caller.
  create_dlq = var.enable_dead_letter && var.dead_letter_topic_id == null

  dlq_topic_id = var.enable_dead_letter ? (
    local.create_dlq ? google_pubsub_topic.dead_letter[0].id : var.dead_letter_topic_id
  ) : null
}

resource "google_pubsub_topic" "this" {
  project                    = var.project_id
  name                       = var.topic_name
  labels                     = var.labels
  message_retention_duration = var.topic_message_retention_duration

  # Restrict which regions store messages (data residency).
  dynamic "message_storage_policy" {
    for_each = length(var.allowed_persistence_regions) > 0 ? [1] : []
    content {
      allowed_persistence_regions = var.allowed_persistence_regions
    }
  }

  # Customer-managed encryption key (CMEK) for messages at rest.
  kms_key_name = var.kms_key_name

  # Enforce an Avro/Protobuf schema on every published message.
  dynamic "schema_settings" {
    for_each = var.schema_id == null ? [] : [1]
    content {
      schema   = var.schema_id
      encoding = var.schema_encoding
    }
  }
}

# Optional managed dead-letter topic for messages that exhaust their retries.
resource "google_pubsub_topic" "dead_letter" {
  count = local.create_dlq ? 1 : 0

  project                    = var.project_id
  name                       = "${var.topic_name}-dlq"
  labels                     = merge(var.labels, { role = "dead-letter" })
  message_retention_duration = var.dead_letter_message_retention_duration
  kms_key_name               = var.kms_key_name
}

resource "google_pubsub_subscription" "this" {
  for_each = var.subscriptions

  project = var.project_id
  name    = each.key
  topic   = google_pubsub_topic.this.id
  labels  = merge(var.labels, lookup(each.value, "labels", {}))

  ack_deadline_seconds       = each.value.ack_deadline_seconds
  message_retention_duration = each.value.message_retention_duration
  retain_acked_messages      = each.value.retain_acked_messages
  enable_message_ordering    = each.value.enable_message_ordering
  enable_exactly_once_delivery = each.value.enable_exactly_once_delivery
  filter                     = each.value.filter

  # Exponential backoff between redelivery attempts.
  dynamic "retry_policy" {
    for_each = each.value.minimum_backoff == null ? [] : [1]
    content {
      minimum_backoff = each.value.minimum_backoff
      maximum_backoff = each.value.maximum_backoff
    }
  }

  # After max_delivery_attempts, forward the message to the dead-letter topic.
  dynamic "dead_letter_policy" {
    for_each = var.enable_dead_letter ? [1] : []
    content {
      dead_letter_topic     = local.dlq_topic_id
      max_delivery_attempts = each.value.max_delivery_attempts
    }
  }

  # Time-based expiration of the subscription itself when idle.
  dynamic "expiration_policy" {
    for_each = each.value.expiration_ttl == null ? [] : [1]
    content {
      ttl = each.value.expiration_ttl
    }
  }

  # Push delivery: Pub/Sub POSTs messages to an authenticated HTTPS endpoint.
  dynamic "push_config" {
    for_each = each.value.push_endpoint == null ? [] : [1]
    content {
      push_endpoint = each.value.push_endpoint
      attributes    = lookup(each.value, "push_attributes", null)

      dynamic "oidc_token" {
        for_each = each.value.push_oidc_service_account == null ? [] : [1]
        content {
          service_account_email = each.value.push_oidc_service_account
          audience              = lookup(each.value, "push_oidc_audience", null)
        }
      }
    }
  }
}

# Grant the Pub/Sub service agent permission to publish into the DLQ topic.
resource "google_pubsub_topic_iam_member" "dlq_publisher" {
  count = var.enable_dead_letter ? 1 : 0

  project = var.project_id
  topic   = local.dlq_topic_id
  role    = "roles/pubsub.publisher"
  member  = local.pubsub_service_agent
}

# Grant the service agent subscriber on each source subscription so it can
# ack messages it has forwarded to the dead-letter topic.
resource "google_pubsub_subscription_iam_member" "dlq_subscriber" {
  for_each = var.enable_dead_letter ? var.subscriptions : {}

  project      = var.project_id
  subscription = google_pubsub_subscription.this[each.key].name
  role         = "roles/pubsub.subscriber"
  member       = local.pubsub_service_agent
}

variables.tf

variable "project_id" {
  description = "GCP project ID that hosts the topic and subscriptions."
  type        = string
}

variable "topic_name" {
  description = "Pub/Sub topic name. 3-255 chars, must not start with 'goog'."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z][A-Za-z0-9._~%+-]{2,254}$", var.topic_name)) && !startswith(lower(var.topic_name), "goog")
    error_message = "topic_name must be 3-255 chars, start with a letter, and must not start with 'goog'."
  }
}

variable "topic_message_retention_duration" {
  description = "How long the TOPIC retains messages (enables replay across new subs). 10m to 31 days, as seconds string e.g. '604800s'. Null disables topic retention."
  type        = string
  default     = "604800s" # 7 days

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

variable "allowed_persistence_regions" {
  description = "Regions allowed to store messages (data residency). Empty list = no restriction (global)."
  type        = list(string)
  default     = []
}

variable "kms_key_name" {
  description = "Cloud KMS CryptoKey resource ID for CMEK encryption of messages at rest. Null uses Google-managed keys."
  type        = string
  default     = null
}

variable "schema_id" {
  description = "Resource ID of a Pub/Sub schema to enforce on published messages. Null disables schema validation."
  type        = string
  default     = null
}

variable "schema_encoding" {
  description = "Encoding messages must use when a schema is set: JSON or BINARY."
  type        = string
  default     = "JSON"

  validation {
    condition     = contains(["JSON", "BINARY"], var.schema_encoding)
    error_message = "schema_encoding must be JSON or BINARY."
  }
}

variable "enable_dead_letter" {
  description = "Attach a dead-letter policy to every subscription. Creates a '<topic>-dlq' topic unless dead_letter_topic_id is supplied."
  type        = bool
  default     = true
}

variable "dead_letter_topic_id" {
  description = "Existing dead-letter topic ID (projects/P/topics/T). Null creates a managed '<topic>-dlq' topic when enable_dead_letter is true."
  type        = string
  default     = null
}

variable "dead_letter_message_retention_duration" {
  description = "Retention on the managed DLQ topic so failures are inspectable. Seconds string, 10m to 31 days."
  type        = string
  default     = "1209600s" # 14 days
}

variable "subscriptions" {
  description = <<-EOT
    Map of subscription name => settings. Each object supports:
      ack_deadline_seconds         - 10..600, seconds to ack before redelivery (default 60).
      message_retention_duration   - retention on the SUB for seek/replay (default '604800s').
      retain_acked_messages        - keep acked messages for seek (default false).
      enable_message_ordering      - in-order delivery per ordering key (default false).
      enable_exactly_once_delivery - exactly-once for pull subs (default false).
      max_delivery_attempts        - 5..100, attempts before dead-lettering (default 5).
      minimum_backoff/maximum_backoff - retry backoff window as seconds strings (default 10s/600s).
      filter                       - server-side attribute filter expression ("" = none).
      expiration_ttl               - TTL before an idle sub is deleted ("" never expires; null = default 31d).
      push_endpoint                - HTTPS URL for Push delivery (null = Pull).
      push_oidc_service_account    - SA email used to mint the OIDC token for Push.
      push_oidc_audience / push_attributes - optional Push tuning.
      labels                       - extra labels merged onto the sub.
  EOT
  type = map(object({
    ack_deadline_seconds         = optional(number, 60)
    message_retention_duration   = optional(string, "604800s")
    retain_acked_messages        = optional(bool, false)
    enable_message_ordering      = optional(bool, false)
    enable_exactly_once_delivery = optional(bool, false)
    max_delivery_attempts        = optional(number, 5)
    minimum_backoff              = optional(string, "10s")
    maximum_backoff              = optional(string, "600s")
    filter                       = optional(string, "")
    expiration_ttl               = optional(string)
    push_endpoint                = optional(string)
    push_oidc_service_account    = optional(string)
    push_oidc_audience           = optional(string)
    push_attributes              = optional(map(string))
    labels                       = optional(map(string), {})
  }))
  default = {}

  validation {
    condition = alltrue([
      for s in values(var.subscriptions) :
      s.ack_deadline_seconds >= 10 && s.ack_deadline_seconds <= 600
    ])
    error_message = "Each subscription ack_deadline_seconds must be between 10 and 600."
  }

  validation {
    condition = alltrue([
      for s in values(var.subscriptions) :
      s.max_delivery_attempts >= 5 && s.max_delivery_attempts <= 100
    ])
    error_message = "Each subscription max_delivery_attempts must be between 5 and 100."
  }
}

variable "labels" {
  description = "Labels applied to the topic, DLQ topic, and every subscription."
  type        = map(string)
  default     = {}
}

outputs.tf

output "topic_id" {
  description = "Fully qualified topic ID (projects/PROJECT/topics/NAME)."
  value       = google_pubsub_topic.this.id
}

output "topic_name" {
  description = "Short name of the topic."
  value       = google_pubsub_topic.this.name
}

output "dead_letter_topic_id" {
  description = "ID of the dead-letter topic in use (managed or supplied), or null when dead-lettering is disabled."
  value       = local.dlq_topic_id
}

output "subscription_ids" {
  description = "Map of subscription name => fully qualified subscription ID."
  value       = { for k, s in google_pubsub_subscription.this : k => s.id }
}

output "subscription_names" {
  description = "Map of subscription name => short name."
  value       = { for k, s in google_pubsub_subscription.this : k => s.name }
}

output "pubsub_service_agent" {
  description = "The Pub/Sub service agent member granted DLQ publisher/subscriber roles."
  value       = local.pubsub_service_agent
}

How to use it

# Runtime identity for the push consumer (e.g. a Cloud Run handler).
resource "google_service_account" "orders_push" {
  project      = var.project_id
  account_id   = "orders-events-push"
  display_name = "Pub/Sub push identity for orders events"
}

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

  project_id = var.project_id
  topic_name = "orders-events"

  # Keep 7 days of messages on the topic so a brand-new subscriber can replay.
  topic_message_retention_duration = "604800s"
  allowed_persistence_regions      = ["asia-south1", "asia-south2"] # data residency

  enable_dead_letter = true # creates orders-events-dlq + wires service-agent IAM

  subscriptions = {
    # Pull worker that needs strict ordering and exactly-once semantics.
    "orders-events-fulfilment" = {
      ack_deadline_seconds         = 120
      enable_message_ordering      = true
      enable_exactly_once_delivery = true
      max_delivery_attempts        = 8
      minimum_backoff              = "10s"
      maximum_backoff              = "300s"
      retain_acked_messages        = true # allow seek-to-time replays
    }

    # Push subscription to a Cloud Run analytics endpoint, filtered to one type.
    "orders-events-analytics" = {
      ack_deadline_seconds      = 30
      filter                    = "attributes.event_type = \"order.created\""
      push_endpoint             = "https://orders-analytics-xyz.a.run.app/pubsub"
      push_oidc_service_account = google_service_account.orders_push.email
    }
  }

  labels = {
    team        = "commerce"
    environment = "prod"
  }
}

# Downstream: allow a publisher service account to publish to the topic,
# wiring it to the module's topic_id output.
resource "google_pubsub_topic_iam_member" "publisher" {
  project = var.project_id
  topic   = module.pub_sub.topic_id # <- module output
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:checkout-api@${var.project_id}.iam.gserviceaccount.com"
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  topic_name = "..."
}

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

cd live/prod/pubsub && 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 hosting the topic and subscriptions.
topic_name string yes Topic name; 3-255 chars, starts with a letter, not goog*.
topic_message_retention_duration string "604800s" no Topic-level retention for cross-sub replay; null disables.
allowed_persistence_regions list(string) [] no Regions allowed to store messages (data residency); empty = global.
kms_key_name string null no Cloud KMS key for CMEK encryption at rest; null = Google-managed.
schema_id string null no Pub/Sub schema resource ID to enforce on messages; null disables.
schema_encoding string "JSON" no Encoding when a schema is set: JSON or BINARY.
enable_dead_letter bool true no Attach a dead-letter policy to every subscription.
dead_letter_topic_id string null no Existing DLQ topic ID; null creates a managed <topic>-dlq.
dead_letter_message_retention_duration string "1209600s" no Retention on the managed DLQ topic.
subscriptions map(object) {} no Map of subscription name => delivery settings (see object schema above).
labels map(string) {} no Labels applied to the topic, DLQ topic, and all subscriptions.

subscriptions object fields

Field Type Default Description
ack_deadline_seconds number 60 Seconds to ack before redelivery (10-600).
message_retention_duration string "604800s" Sub-level retention for seek/replay.
retain_acked_messages bool false Keep acked messages so seek can replay them.
enable_message_ordering bool false In-order delivery per ordering key.
enable_exactly_once_delivery bool false Exactly-once delivery (pull subscriptions).
max_delivery_attempts number 5 Attempts before dead-lettering (5-100).
minimum_backoff / maximum_backoff string "10s" / "600s" Retry backoff window.
filter string "" Server-side attribute filter; "" = no filter.
expiration_ttl string null Idle-sub TTL; "" never expires, null = default 31d.
push_endpoint string null HTTPS endpoint for Push; null = Pull.
push_oidc_service_account string null SA email used to mint the OIDC token for Push.
push_oidc_audience string null Optional OIDC audience override for Push.
push_attributes map(string) null Extra Push delivery attributes.
labels map(string) {} Extra labels merged onto the subscription.

Outputs

Name Description
topic_id Fully qualified topic ID (projects/PROJECT/topics/NAME).
topic_name Short name of the topic.
dead_letter_topic_id ID of the DLQ topic in use, or null when disabled.
subscription_ids Map of subscription name => fully qualified subscription ID.
subscription_names Map of subscription name => short name.
pubsub_service_agent Pub/Sub service-agent member granted DLQ publisher/subscriber roles.

Enterprise scenario

A retail platform publishes every order lifecycle event to a single orders-events topic and fans it out to four independent consumers: a fulfilment worker (Pull, enable_message_ordering = true + enable_exactly_once_delivery = true so each order_id is processed in sequence exactly once), a Cloud Run analytics endpoint (Push, server-side filter on order.created only), a BigQuery loader, and a fraud-scoring service. Dead-lettering is on, so a malformed event that fails eight delivery attempts lands in orders-events-dlq for the on-call engineer to inspect instead of redelivering forever and stalling the ordered stream. Because the topic keeps seven days of retention, when the fraud team ships a bug fix they seek their subscription back to the incident window and replay — without any republish from upstream.

Best practices

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