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
- You run Cloud Run services that react to platform events — a thumbnail generator on GCS finalize, an audit-trail sink on IAM policy changes, an indexer on Firestore writes.
- You need Cloud Audit Log triggers (e.g. fire whenever someone calls
SetIamPolicyorstorage.buckets.create) and want the event filters and required service-account roles managed as code, not clicked in the console. - You are standardising event routing across many services/teams and want one tested module instead of N hand-rolled triggers with inconsistent IAM.
- You want the transport Pub/Sub topic for message-based triggers created and least-privilege-bound alongside the trigger, so there’s no orphaned topic drift.
- You’re enforcing regional placement, labels, and retry/IAM hygiene through policy and need the trigger surface to be declarative.
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 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/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
- Enable Data Access audit logs first. Audit-log triggers only fire for log entries that are actually generated — for many services the Data Access logs (
ADMIN_READ/DATA_WRITE) are off by default. Managegoogle_project_iam_audit_configalongside this module so the trigger has events to match. - Use a dedicated, least-privilege trigger SA. Don’t reuse the Compute Engine default SA. Give the trigger SA only
eventarc.eventReceiver,pubsub.publisheron its own transport topic, andrun.invokeron the single destination — all of which this module wires for you. Never grant project-widerun.invoker. - Scope filters tightly to control cost and noise. Each delivered event is a Cloud Run invocation; a broad
serviceName-only audit filter on a busy project can fire thousands of times an hour. AddmethodName(andmatch-path-patternonfullResourceNamefor resource scoping) so you only pay for the events you act on. - Name and label for blast-radius and ownership. Encode source and intent in
name(e.g.gcs-finalize-thumbnailer) and setteam/environmentlabels so triggers are filterable in logs, billing, and policy — this module propagates labels to the transport topic too. - Pin the region to the source where it matters. Direct sources like Cloud Storage require the trigger in the same region as the bucket; mismatches fail at apply. Drive
locationfrom the source resource’s region rather than hardcoding. - Build reliability around at-least-once delivery. Eventarc delivers at least once, so the destination must be idempotent (dedupe on the CloudEvent
id). Set a sanetransport_message_retentionand alert on the transport topic backlog (as shown above) so transient sink outages don’t silently lose events.