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
- You are building application-to-service eventing where your own services publish domain events (
OrderPlaced,InvoiceApproved) and multiple independent subscribers react — and you want a stable endpoint decoupled from consumers. - You need push-based fan-out with built-in retry, dead-lettering, and at-least-once delivery, and you do not want to operate a broker (use a custom topic, not Event Hubs or Service Bus, when the payload is a discrete event rather than a high-throughput stream).
- You want subscribers delivered to using a managed identity + RBAC rather than handing out SAS tokens or webhook secrets.
- You must satisfy network security policy: no public ingestion, or ingestion locked to a known set of egress IPs / NAT gateways via an inbound IP firewall.
- You are standardising many topics across teams (one per bounded context) and need naming, tagging, schema, and diagnostics to be identical everywhere.
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 config — live/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 config — live/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
- Lock the input schema on day one.
input_schemacannot be changed in place — Terraform will force a destroy/recreate, which orphans every event subscription. Default toCloudEventSchemaV1_0for interoperability and only chooseCustomEventSchemawhen you must ingest a legacy payload shape. - Prefer identity over keys. Set
local_auth_enabled = falseand publish with Entra ID tokens; use a managed identity for delivery (delivery_identityon subscriptions) so neither publishers nor subscribers rely on SAS secrets. If you must keep keys, rotate using the primary/secondary pair so there is zero downtime. - Close the network. For internal-only eventing set
public_network_access_enabled = falseand add a private endpoint; when public access is required, never leaveinbound_ip_rulesempty — pin it to known NAT/egress IPs so the ingestion endpoint is not open to the world. - Wire diagnostics and dead-lettering. Always pass
log_analytics_workspace_idsoDeliveryFailuresandPublishFailuresare queryable, and configure dead-letter storage plus a saneretry_policy(e.g. 24h TTL) on each subscription so poison events are not lost silently. - Mind cost and limits. Event Grid bills per operation (publish + delivery + retries), so aggressive retry policies multiply spend on flaky subscribers — tune
max_delivery_attempts. A topic caps at 5 MB per publish batch and 1 MB per event; size events accordingly rather than embedding large payloads. - Standardise naming and tags. Use a predictable convention such as
evgt-<domain>-<env>-<region>and enforceenvironment/owner/domaintags through the module so topics are discoverable, attributable, and cost-allocatable across many teams.