IaC Azure

Terraform Module: Azure Event Grid Topic — Production-Ready Eventing with Identity, Private Endpoints, and Inbound Schema Control

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for Azure Event Grid Topic: managed identity, public-network lockdown, IP firewall, inbound IP filtering, and input schema mapping wired for production. 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 "azurerm" {
  features {}
}

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

  name                = "..."  # Topic name (3-50 chars, alphanumeric + hyphens); drives…
  resource_group_name = "..."  # Resource group to create the topic in.
  location            = "..."  # Azure region (e.g. `centralindia`).
}

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

What this module is

An Azure Event Grid Topic is a custom (publisher-defined) topic endpoint that accepts events from your own applications and fans them out to subscribers via push delivery (webhooks, Azure Functions, Service Bus, Event Hubs, Storage Queues, and more). Unlike system topics — which Azure resources emit automatically — a custom topic gives you the HTTPS ingestion endpoint and the two access keys that your producers use to publish. It is the backbone of a “publish once, react many” event-driven design on Azure: the publisher never needs to know who the consumers are.

Wrapping azurerm_eventgrid_topic in a reusable module matters because a correct production topic is more than three lines of HCL. You need an opinionated default for the input schema (it is immutable after creation — get it wrong and you recreate the topic), a managed identity so the topic can deliver events to subscribers using RBAC instead of shared secrets, network controls (public_network_access_enabled, an IP firewall, and inbound_ip_rule blocks) so the ingestion endpoint is not open to the entire internet, and consistent diagnostic wiring. This module bakes those decisions in once, exposes the few knobs teams actually flip, and emits the endpoint plus the access keys (as sensitive outputs) so downstream modules can wire publishers without copy-pasting portal values.

When to use it

Reach for a system topic instead if you only need to react to Azure resource events (Blob created, Resource Group write). Reach for Event Hubs if you need millions of events/sec with consumer-group replay, or Service Bus if you need ordered FIFO sessions and transactional queues.

Module structure

terraform-module-azure-event-grid/
├── versions.tf      # provider + Terraform version pinning
├── main.tf          # azurerm_eventgrid_topic + diagnostics
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # id, endpoint, keys (sensitive), identity

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

resource "azurerm_eventgrid_topic" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  # Schema is IMMUTABLE after creation. Changing it forces replacement.
  input_schema = var.input_schema

  # Network posture.
  public_network_access_enabled = var.public_network_access_enabled
  local_auth_enabled            = var.local_auth_enabled

  # Inbound IP firewall. Only honoured when public network access is enabled.
  dynamic "inbound_ip_rule" {
    for_each = var.public_network_access_enabled ? var.inbound_ip_rules : []
    content {
      ip_mask = inbound_ip_rule.value.ip_mask
      action  = inbound_ip_rule.value.action
    }
  }

  # Managed identity so the topic can deliver to subscribers via RBAC.
  dynamic "identity" {
    for_each = var.identity_type == null ? [] : [1]
    content {
      type         = var.identity_type
      identity_ids = var.identity_type == "UserAssigned" ? var.identity_ids : null
    }
  }

  # Custom input schema mapping (only valid when input_schema = "CustomEventSchema").
  dynamic "input_mapping_fields" {
    for_each = var.input_schema == "CustomEventSchema" && var.input_mapping_fields != null ? [var.input_mapping_fields] : []
    content {
      id           = input_mapping_fields.value.id
      topic        = input_mapping_fields.value.topic
      event_type   = input_mapping_fields.value.event_type
      event_time   = input_mapping_fields.value.event_time
      data_version = input_mapping_fields.value.data_version
      subject      = input_mapping_fields.value.subject
    }
  }

  dynamic "input_mapping_default_values" {
    for_each = var.input_schema == "CustomEventSchema" && var.input_mapping_default_values != null ? [var.input_mapping_default_values] : []
    content {
      event_type   = input_mapping_default_values.value.event_type
      data_version = input_mapping_default_values.value.data_version
      subject      = input_mapping_default_values.value.subject
    }
  }

  tags = var.tags
}

resource "azurerm_monitor_diagnostic_setting" "this" {
  count = var.log_analytics_workspace_id == null ? 0 : 1

  name                       = "diag-${var.name}"
  target_resource_id         = azurerm_eventgrid_topic.this.id
  log_analytics_workspace_id = var.log_analytics_workspace_id

  enabled_log {
    category = "DeliveryFailures"
  }

  enabled_log {
    category = "PublishFailures"
  }

  enabled_metric {
    category = "AllMetrics"
  }
}

variables.tf

variable "name" {
  description = "Name of the Event Grid Topic. 3-50 chars, alphanumeric and hyphens; globally unique within its region for the endpoint host."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9-]{3,50}$", var.name))
    error_message = "name must be 3-50 characters and contain only letters, numbers, and hyphens."
  }
}

variable "resource_group_name" {
  description = "Name of the resource group in which to create the topic."
  type        = string
}

variable "location" {
  description = "Azure region for the topic (e.g. centralindia, eastus)."
  type        = string
}

variable "input_schema" {
  description = "Event schema the topic accepts. IMMUTABLE after creation. One of EventGridSchema, CloudEventSchemaV1_0, CustomEventSchema."
  type        = string
  default     = "CloudEventSchemaV1_0"

  validation {
    condition     = contains(["EventGridSchema", "CloudEventSchemaV1_0", "CustomEventSchema"], var.input_schema)
    error_message = "input_schema must be EventGridSchema, CloudEventSchemaV1_0, or CustomEventSchema."
  }
}

variable "public_network_access_enabled" {
  description = "Whether the ingestion endpoint is reachable from the public internet. Set false to force private endpoint access only."
  type        = bool
  default     = true
}

variable "local_auth_enabled" {
  description = "Whether shared access key (SAS) authentication is allowed for publishing. Set false to require Microsoft Entra ID (AAD) auth only."
  type        = bool
  default     = true
}

variable "inbound_ip_rules" {
  description = "List of inbound IP firewall rules. Only applied when public_network_access_enabled is true."
  type = list(object({
    ip_mask = string
    action  = optional(string, "Allow")
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.inbound_ip_rules : contains(["Allow"], r.action)
    ])
    error_message = "inbound_ip_rule action only supports 'Allow' on Event Grid topics."
  }
}

variable "identity_type" {
  description = "Managed identity type for the topic: SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null for none."
  type        = string
  default     = "SystemAssigned"

  validation {
    condition     = var.identity_type == null || contains(["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"], var.identity_type)
    error_message = "identity_type must be SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null."
  }
}

variable "identity_ids" {
  description = "User-assigned managed identity resource IDs. Required when identity_type includes UserAssigned."
  type        = list(string)
  default     = []
}

variable "input_mapping_fields" {
  description = "Field mapping for CustomEventSchema. Maps your payload fields to Event Grid envelope properties. Ignored unless input_schema is CustomEventSchema."
  type = object({
    id           = optional(string)
    topic        = optional(string)
    event_type   = optional(string)
    event_time   = optional(string)
    data_version = optional(string)
    subject      = optional(string)
  })
  default = null
}

variable "input_mapping_default_values" {
  description = "Default values for unmapped fields in CustomEventSchema. Ignored unless input_schema is CustomEventSchema."
  type = object({
    event_type   = optional(string)
    data_version = optional(string)
    subject      = optional(string)
  })
  default = null
}

variable "log_analytics_workspace_id" {
  description = "Resource ID of a Log Analytics workspace for diagnostics. Set null to skip diagnostic settings."
  type        = string
  default     = null
}

variable "tags" {
  description = "Tags applied to the topic."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Event Grid Topic."
  value       = azurerm_eventgrid_topic.this.id
}

output "name" {
  description = "Name of the Event Grid Topic."
  value       = azurerm_eventgrid_topic.this.name
}

output "endpoint" {
  description = "HTTPS endpoint publishers POST events to."
  value       = azurerm_eventgrid_topic.this.endpoint
}

output "primary_access_key" {
  description = "Primary shared access key for publishing (sensitive)."
  value       = azurerm_eventgrid_topic.this.primary_access_key
  sensitive   = true
}

output "secondary_access_key" {
  description = "Secondary shared access key, used for zero-downtime key rotation (sensitive)."
  value       = azurerm_eventgrid_topic.this.secondary_access_key
  sensitive   = true
}

output "identity_principal_id" {
  description = "Principal ID of the system-assigned identity, for RBAC role assignments on subscriber resources. Null when no system-assigned identity."
  value       = try(azurerm_eventgrid_topic.this.identity[0].principal_id, null)
}

How to use it

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

  name                = "evgt-orders-prod-cin"
  resource_group_name = azurerm_resource_group.eventing.name
  location            = "centralindia"

  # CloudEvents 1.0 is the interoperable default for new workloads.
  input_schema = "CloudEventSchemaV1_0"

  # Lock the door: no SAS publishing, only known egress IPs.
  local_auth_enabled            = false
  public_network_access_enabled = true
  inbound_ip_rules = [
    { ip_mask = "20.193.45.0/24" }, # AKS egress NAT gateway
    { ip_mask = "20.193.46.10/32" }, # legacy on-prem publisher
  ]

  identity_type = "SystemAssigned"

  log_analytics_workspace_id = azurerm_log_analytics_workspace.platform.id

  tags = {
    environment = "prod"
    domain      = "orders"
    owner       = "commerce-platform"
  }
}

# Downstream: subscribe an Azure Function and let the topic's identity deliver via RBAC.
resource "azurerm_eventgrid_event_subscription" "order_processor" {
  name  = "sub-order-processor"
  scope = module.event_grid_topic.id

  azure_function_endpoint {
    function_id = "${azurerm_linux_function_app.processor.id}/functions/ProcessOrder"
  }

  delivery_identity {
    type = "SystemAssigned"
  }

  retry_policy {
    max_delivery_attempts = 30
    event_time_to_live    = 1440
  }
}

# Grant the topic's identity rights on the Function so identity-based delivery works.
resource "azurerm_role_assignment" "topic_to_function" {
  scope                = azurerm_linux_function_app.processor.id
  role_definition_name = "Contributor"
  principal_id         = module.event_grid_topic.identity_principal_id
}

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 = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/event_grid/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

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

cd live/prod/event_grid && 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
name string Yes Topic name (3-50 chars, alphanumeric + hyphens); drives the endpoint host.
resource_group_name string Yes Resource group to create the topic in.
location string Yes Azure region (e.g. centralindia).
input_schema string "CloudEventSchemaV1_0" No Accepted event schema. Immutable after creation. One of EventGridSchema, CloudEventSchemaV1_0, CustomEventSchema.
public_network_access_enabled bool true No Allow ingestion from the public internet. Set false for private-endpoint-only.
local_auth_enabled bool true No Allow SAS key publishing. Set false to require Entra ID auth.
inbound_ip_rules list(object({ ip_mask, action })) [] No IP firewall allow-list; applied only when public access is enabled.
identity_type string "SystemAssigned" No SystemAssigned, UserAssigned, "SystemAssigned, UserAssigned", or null.
identity_ids list(string) [] No User-assigned identity IDs; required when type includes UserAssigned.
input_mapping_fields object({...}) null No Payload-to-envelope field mapping; used only with CustomEventSchema.
input_mapping_default_values object({...}) null No Default envelope values for CustomEventSchema.
log_analytics_workspace_id string null No Workspace ID for diagnostics; null skips diagnostic settings.
tags map(string) {} No Tags applied to the topic.

Outputs

Name Description
id Resource ID of the Event Grid Topic.
name Name of the topic.
endpoint HTTPS endpoint that publishers POST events to.
primary_access_key Primary SAS publishing key (sensitive).
secondary_access_key Secondary SAS key for zero-downtime rotation (sensitive).
identity_principal_id Principal ID of the system-assigned identity, for RBAC on subscribers (null if none).

Enterprise scenario

A retail commerce platform runs one Event Grid Topic per bounded context (orders, payments, inventory), each provisioned by this module from a per-domain Terraform stack. Producers run in AKS and publish through the cluster’s NAT gateway, so local_auth_enabled = false enforces Entra ID auth and inbound_ip_rules pins ingestion to the gateway’s public IP — no shared keys leave the cluster. Each topic’s system-assigned identity is granted least-privilege roles on its subscriber Functions and a Service Bus archive queue, so delivery is identity-based and fully auditable, and DeliveryFailures/PublishFailures logs flow to the platform Log Analytics workspace for alerting on dead-lettered events.

Best practices

TerraformAzureEvent Grid TopicModuleIaC
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