IaC GCP

Terraform Module: GCP Eventarc — Event-Driven Routing Without the Boilerplate

Quick take — A reusable hashicorp/google Terraform module for google_eventarc_trigger — route Cloud Audit Logs, Pub/Sub, and direct GCP events to Cloud Run with a least-privilege service account, transport topic, and event filters. 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 "eventarc" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-eventarc?ref=v1.0.0"

  project_id                    = "..."  # Project that owns the trigger and transport topic.
  name                          = "..."  # Trigger name; validated to GCP naming rules.
  location                      = "..."  # Trigger region (e.g. `asia-south1`) or `global`.
  trigger_service_account       = "..."  # SA email Eventarc uses to receive events and invoke the…
  event_filters                 = {}     # Matching criteria; must include `type`.
  destination_cloud_run_service = "..."  # Cloud Run service that receives the CloudEvent.
}

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

What this module is

Eventarc is GCP’s managed event-routing layer. It takes events from three classes of producer — direct provider events (Cloud Storage object finalization, Firestore document writes, Pub/Sub Loud events), Cloud Audit Log entries (any serviceName + methodName an admin activity log emits), and raw Pub/Sub messages — and delivers them as CloudEvents over HTTP to a sink, most commonly a Cloud Run service. The mechanics underneath are fiddly: an Audit-Log trigger needs the Data Access audit log enabled on the source service, every trigger needs a service account with roles/eventarc.eventReceiver (and roles/pubsub.publisher for indirect sources), and Pub/Sub triggers either consume or auto-create a transport topic. Forget one piece and the trigger silently provisions but never fires.

Wrapping google_eventarc_trigger in a module collapses that into a typed contract. Callers declare what event they want and where it goes; the module wires the matching-criteria filters, the destination block, the (optional) transport topic, and the IAM bindings the trigger needs to actually receive and forward events. You stop copy-pasting the roles/eventarc.eventReceiver grant into every service and you stop debugging triggers that exist on paper but drop every event.

When to use it

Reach for raw resources only for a genuinely one-off trigger. Anything you’ll repeat — and Eventarc triggers are almost always repeated per service — belongs in a module.

Module structure

terraform-module-gcp-eventarc/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # google_eventarc_trigger + transport topic + IAM
├── variables.tf     # typed, validated inputs
└── outputs.tf       # trigger id/name + key attributes

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Eventarc requires a Pub/Sub transport topic for indirect (audit-log /
  # message-bus) event types. Direct sources like Cloud Storage do NOT use one.
  # Create a topic when the caller asked for one AND did not bring their own.
  manage_transport_topic = var.create_transport_topic && var.transport_topic == null

  transport_topic_id = local.manage_transport_topic ? google_pubsub_topic.transport[0].id : var.transport_topic
}

# ---------------------------------------------------------------------------
# Optional transport topic for Pub/Sub / Cloud Audit Log triggers.
# ---------------------------------------------------------------------------
resource "google_pubsub_topic" "transport" {
  count = local.manage_transport_topic ? 1 : 0

  project = var.project_id
  name    = coalesce(var.transport_topic_name, "${var.name}-transport")
  labels  = var.labels

  message_retention_duration = var.transport_message_retention
}

# ---------------------------------------------------------------------------
# Eventarc trigger.
# ---------------------------------------------------------------------------
resource "google_eventarc_trigger" "this" {
  project  = var.project_id
  name     = var.name
  location = var.location
  labels   = var.labels

  # The identity Eventarc uses to receive the event and invoke the sink.
  # Must hold roles/eventarc.eventReceiver (granted below) and, for the
  # Cloud Run sink, roles/run.invoker.
  service_account = var.trigger_service_account

  # event_filters express the matching criteria. The "type" attribute is
  # always required; audit-log triggers additionally need serviceName and
  # methodName, and some direct sources need a "bucket" or resource filter.
  dynamic "matching_criteria" {
    for_each = var.event_filters
    content {
      attribute = matching_criteria.key
      value     = matching_criteria.value
      # operator "match-path-pattern" enables wildcard path matching on
      # resource attributes (e.g. fullResourceName). Omitted = exact match.
      operator = lookup(var.event_filter_operators, matching_criteria.key, null)
    }
  }

  destination {
    cloud_run_service {
      service = var.destination_cloud_run_service
      region  = coalesce(var.destination_cloud_run_region, var.location)
      path    = var.destination_path
    }
  }

  # Only set for indirect event types (audit log / Pub/Sub). Direct provider
  # events (e.g. google.cloud.storage.object.v1.finalized) leave this null.
  dynamic "transport" {
    for_each = local.transport_topic_id == null ? [] : [1]
    content {
      pubsub {
        topic = local.transport_topic_id
      }
    }
  }

  # Pin events to a Cloud Run revision/channel when you need controlled rollout.
  channel = var.channel

  depends_on = [
    google_project_iam_member.event_receiver,
  ]
}

# ---------------------------------------------------------------------------
# IAM: the trigger SA must be able to receive Eventarc events. Optionally
# grant publisher on the transport topic (needed for audit-log sources) and
# run.invoker on the destination service.
# ---------------------------------------------------------------------------
resource "google_project_iam_member" "event_receiver" {
  count = var.manage_iam ? 1 : 0

  project = var.project_id
  role    = "roles/eventarc.eventReceiver"
  member  = "serviceAccount:${var.trigger_service_account}"
}

resource "google_pubsub_topic_iam_member" "transport_publisher" {
  count = var.manage_iam && local.transport_topic_id != null ? 1 : 0

  project = var.project_id
  topic   = local.transport_topic_id
  role    = "roles/pubsub.publisher"
  member  = "serviceAccount:${var.trigger_service_account}"
}

resource "google_cloud_run_v2_service_iam_member" "invoker" {
  count = var.manage_iam && var.grant_run_invoker ? 1 : 0

  project  = var.project_id
  location = coalesce(var.destination_cloud_run_region, var.location)
  name     = var.destination_cloud_run_service
  role     = "roles/run.invoker"
  member   = "serviceAccount:${var.trigger_service_account}"
}

variables.tf

variable "project_id" {
  description = "GCP project ID that owns the Eventarc trigger and transport topic."
  type        = string
}

variable "name" {
  description = "Trigger name. Lowercase letters, numbers and hyphens; must start with a letter."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{0,62}$", var.name))
    error_message = "name must start with a letter and contain only lowercase letters, numbers, and hyphens (max 63 chars)."
  }
}

variable "location" {
  description = "Region for the trigger (e.g. asia-south1, us-central1, or 'global' for some global sources)."
  type        = string

  validation {
    condition     = var.location == "global" || can(regex("^[a-z]+-[a-z]+[0-9]$", var.location))
    error_message = "location must be a valid GCP region (e.g. asia-south1) or 'global'."
  }
}

variable "trigger_service_account" {
  description = "Email of the service account Eventarc uses to receive events and invoke the sink (e.g. eventarc-sa@PROJECT.iam.gserviceaccount.com)."
  type        = string

  validation {
    condition     = can(regex("^[^@]+@[^@]+\\.iam\\.gserviceaccount\\.com$", var.trigger_service_account))
    error_message = "trigger_service_account must be a full service account email ending in .iam.gserviceaccount.com."
  }
}

variable "event_filters" {
  description = <<-EOT
    Map of matching-criteria attribute => value. The "type" key is mandatory.
    Direct example:    { type = "google.cloud.storage.object.v1.finalized", bucket = "my-bucket" }
    Audit-log example: { type = "google.cloud.audit.log.v1.written", serviceName = "storage.googleapis.com", methodName = "storage.buckets.create" }
  EOT
  type        = map(string)

  validation {
    condition     = contains(keys(var.event_filters), "type")
    error_message = "event_filters must include a \"type\" attribute (e.g. google.cloud.audit.log.v1.written)."
  }
}

variable "event_filter_operators" {
  description = "Optional map of attribute => operator (e.g. { fullResourceName = \"match-path-pattern\" }) for wildcard matching."
  type        = map(string)
  default     = {}

  validation {
    condition     = alltrue([for op in values(var.event_filter_operators) : op == "match-path-pattern"])
    error_message = "The only supported operator is \"match-path-pattern\"."
  }
}

variable "destination_cloud_run_service" {
  description = "Name of the Cloud Run service that receives the CloudEvent."
  type        = string
}

variable "destination_cloud_run_region" {
  description = "Region of the destination Cloud Run service. Defaults to the trigger location."
  type        = string
  default     = null
}

variable "destination_path" {
  description = "Relative HTTP path on the Cloud Run service to POST events to (e.g. /events). Null delivers to root."
  type        = string
  default     = null
}

variable "channel" {
  description = "Full resource name of an Eventarc channel for third-party / partner sources. Null for Google sources."
  type        = string
  default     = null
}

variable "create_transport_topic" {
  description = "Create a Pub/Sub transport topic for indirect (audit-log / Pub/Sub) event types. Ignored when transport_topic is set."
  type        = bool
  default     = false
}

variable "transport_topic" {
  description = "ID of an existing Pub/Sub topic to use as transport (projects/PROJECT/topics/NAME). Null lets the module create one when create_transport_topic = true."
  type        = string
  default     = null
}

variable "transport_topic_name" {
  description = "Name for the managed transport topic. Defaults to \"<name>-transport\"."
  type        = string
  default     = null
}

variable "transport_message_retention" {
  description = "Pub/Sub message retention on the managed transport topic (e.g. 600s, 86400s)."
  type        = string
  default     = "600s"
}

variable "manage_iam" {
  description = "Grant roles/eventarc.eventReceiver (and topic publisher when applicable) to the trigger service account."
  type        = bool
  default     = true
}

variable "grant_run_invoker" {
  description = "Also grant roles/run.invoker on the destination Cloud Run service to the trigger service account."
  type        = bool
  default     = true
}

variable "labels" {
  description = "Labels applied to the trigger and managed transport topic."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Fully-qualified Eventarc trigger ID (projects/.../locations/.../triggers/...)."
  value       = google_eventarc_trigger.this.id
}

output "name" {
  description = "Trigger name."
  value       = google_eventarc_trigger.this.name
}

output "etag" {
  description = "Server-generated etag for optimistic concurrency."
  value       = google_eventarc_trigger.this.etag
}

output "service_account" {
  description = "Service account the trigger uses to receive events and invoke the sink."
  value       = google_eventarc_trigger.this.service_account
}

output "transport_topic_id" {
  description = "ID of the transport topic in use (managed or supplied); null for direct event sources."
  value       = local.transport_topic_id
}

output "uid" {
  description = "Server-assigned unique identifier for the trigger."
  value       = google_eventarc_trigger.this.uid
}

How to use it

Here a security service consumes IAM SetIamPolicy audit-log events. The module creates the transport topic, grants the receiver/publisher/invoker roles, and the trigger fires whenever someone changes an IAM policy in the project.

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

  project_id              = "kv-platform-prod"
  name                    = "iam-policy-audit-trigger"
  location                = "asia-south1"
  trigger_service_account = google_service_account.eventarc.email

  # Cloud Audit Log source -> needs a transport topic + publisher role.
  create_transport_topic = true

  event_filters = {
    type        = "google.cloud.audit.log.v1.written"
    serviceName = "cloudresourcemanager.googleapis.com"
    methodName  = "SetIamPolicy"
  }

  destination_cloud_run_service = google_cloud_run_v2_service.audit_sink.name
  destination_cloud_run_region  = "asia-south1"
  destination_path              = "/iam-events"

  grant_run_invoker = true

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

resource "google_service_account" "eventarc" {
  project      = "kv-platform-prod"
  account_id   = "eventarc-iam-audit"
  display_name = "Eventarc IAM audit trigger"
}

# Downstream reference: alert when the trigger's transport topic backs up,
# using the module's transport_topic_id output.
resource "google_monitoring_alert_policy" "transport_backlog" {
  project      = "kv-platform-prod"
  display_name = "Eventarc transport backlog: ${module.eventarc.name}"
  combiner     = "OR"

  conditions {
    display_name = "Undelivered messages on transport topic"
    condition_threshold {
      filter = join("", [
        "resource.type = \"pubsub_topic\" AND ",
        "resource.label.\"topic_id\" = \"${element(split("/", module.eventarc.transport_topic_id), length(split("/", module.eventarc.transport_topic_id)) - 1)}\" AND ",
        "metric.type = \"pubsub.googleapis.com/topic/send_request_count\"",
      ])
      comparison      = "COMPARISON_GT"
      threshold_value = 0
      duration        = "300s"
      aggregations {
        alignment_period   = "60s"
        per_series_aligner = "ALIGN_RATE"
      }
    }
  }
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  project_id = "..."
  name = "..."
  location = "..."
  trigger_service_account = "..."
  event_filters = {}
  destination_cloud_run_service = "..."
}

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

cd live/prod/eventarc && 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 Project that owns the trigger and transport topic.
name string yes Trigger name; validated to GCP naming rules.
location string yes Trigger region (e.g. asia-south1) or global.
trigger_service_account string yes SA email Eventarc uses to receive events and invoke the sink.
event_filters map(string) yes Matching criteria; must include type.
event_filter_operators map(string) {} no Per-attribute operator; only match-path-pattern is supported.
destination_cloud_run_service string yes Cloud Run service that receives the CloudEvent.
destination_cloud_run_region string null no Destination service region; defaults to location.
destination_path string null no HTTP path on the service to POST events to.
channel string null no Eventarc channel resource name for third-party sources.
create_transport_topic bool false no Create a Pub/Sub transport topic for indirect event types.
transport_topic string null no Existing transport topic ID to reuse instead of creating one.
transport_topic_name string null no Name for the managed transport topic; defaults to <name>-transport.
transport_message_retention string "600s" no Message retention on the managed transport topic.
manage_iam bool true no Grant eventarc.eventReceiver (+ topic publisher when applicable) to the SA.
grant_run_invoker bool true no Also grant run.invoker on the destination service to the SA.
labels map(string) {} no Labels applied to trigger and managed topic.

Outputs

Name Description
id Fully-qualified trigger ID (projects/.../locations/.../triggers/...).
name Trigger name.
etag Server-generated etag for optimistic concurrency.
service_account SA the trigger uses to receive events and invoke the sink.
transport_topic_id Transport topic in use (managed or supplied); null for direct sources.
uid Server-assigned unique identifier for the trigger.

Enterprise scenario

A fintech running its core ledger on Cloud Run needs an immutable, near-real-time audit trail for compliance. The platform team instantiates this module once per regulated project with event_filters = { type = "google.cloud.audit.log.v1.written", serviceName = "cloudkms.googleapis.com" }, routing every Cloud KMS admin-activity event to a Cloud Run sink that writes signed records to BigQuery. Because the module provisions the transport topic and the eventReceiver + pubsub.publisher + run.invoker grants atomically, a new project is audit-wired in a single terraform apply with no console clicks and no chance of a half-configured trigger silently dropping security events.

Best practices

TerraformGCPEventarcModuleIaC
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