IaC Azure

Terraform Module: Azure Event Hub — opinionated namespaces and partitioned event streams

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for Azure Event Hub: namespace SKU and capacity, partitioned hubs, message retention, capture to ADLS, and least-privilege authorization rules wired into clean outputs. 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_hub" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-event-hub?ref=v1.0.0"

  namespace_name      = "..."  # Globally unique namespace name (6-50 chars, validated).
  event_hub_name      = "..."  # Name of the event hub (partitioned log) inside the name…
  resource_group_name = "..."  # Resource group for the namespace and hub.
  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

Azure Event Hub is a managed, partition-based event ingestion service — the Azure-native analogue to Apache Kafka. A namespace is the billing and networking boundary (it carries the SKU, throughput/capacity, and private endpoint surface), and inside it you create one or more event hubs, each of which is a log split into a fixed number of partitions. Producers append events; consumer groups read them independently at their own offset. It is the workhorse for telemetry pipelines, clickstream ingestion, and as the front door into Stream Analytics, Functions, or a Databricks/Fabric lakehouse.

The raw resource graph is fiddly to get right every time: the partition count is immutable after creation on Standard/Basic, message_retention is capped by SKU, Capture has its own nested destination block with a brittle path format, and authorization rules must be scoped at the right level with only the rights they need. Wrapping azurerm_eventhub_namespace + azurerm_eventhub in a module bakes those decisions into one reviewed, tagged, version-pinned unit so every team ships a consistent, least-privilege stream instead of copy-pasting a half-correct block.

When to use it

Reach for a different tool when you need per-message TTL, dead-lettering, or topic/subscription fan-out semantics — that is Service Bus, not Event Hubs.

Module structure

terraform-module-azure-event-hub/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Capture is only valid on Standard/Premium/Dedicated; silently ignored on Basic.
  capture_enabled = var.capture != null

  # Auto Inflate only applies to the Standard SKU.
  auto_inflate_enabled = var.sku == "Standard" ? var.auto_inflate_enabled : false
}

resource "azurerm_eventhub_namespace" "this" {
  name                = var.namespace_name
  resource_group_name = var.resource_group_name
  location            = var.location

  sku                           = var.sku
  capacity                      = var.capacity
  auto_inflate_enabled          = local.auto_inflate_enabled
  maximum_throughput_units      = local.auto_inflate_enabled ? var.maximum_throughput_units : null
  public_network_access_enabled = var.public_network_access_enabled
  minimum_tls_version           = var.minimum_tls_version
  local_authentication_enabled  = var.local_authentication_enabled

  dynamic "network_rulesets" {
    for_each = var.network_ruleset != null ? [var.network_ruleset] : []
    content {
      default_action                 = network_rulesets.value.default_action
      public_network_access_enabled  = var.public_network_access_enabled
      trusted_service_access_enabled = network_rulesets.value.trusted_service_access_enabled

      dynamic "ip_rule" {
        for_each = network_rulesets.value.ip_rules
        content {
          ip_mask = ip_rule.value
          action  = "Allow"
        }
      }

      dynamic "virtual_network_rule" {
        for_each = network_rulesets.value.subnet_ids
        content {
          subnet_id                                       = virtual_network_rule.value
          ignore_missing_virtual_network_service_endpoint = false
        }
      }
    }
  }

  tags = var.tags
}

resource "azurerm_eventhub" "this" {
  name              = var.event_hub_name
  namespace_id      = azurerm_eventhub_namespace.this.id
  partition_count   = var.partition_count
  message_retention = var.message_retention

  dynamic "capture_description" {
    for_each = local.capture_enabled ? [var.capture] : []
    content {
      enabled             = true
      encoding            = capture_description.value.encoding
      interval_in_seconds = capture_description.value.interval_in_seconds
      size_limit_in_bytes = capture_description.value.size_limit_in_bytes
      skip_empty_archives = capture_description.value.skip_empty_archives

      destination {
        name                = "EventHubArchive.AzureBlockBlob"
        archive_name_format = capture_description.value.archive_name_format
        blob_container_name = capture_description.value.blob_container_name
        storage_account_id  = capture_description.value.storage_account_id
      }
    }
  }
}

# Least-privilege application rule (e.g. a producer that only needs Send).
resource "azurerm_eventhub_authorization_rule" "app" {
  for_each = var.authorization_rules

  name                = each.key
  namespace_name      = azurerm_eventhub_namespace.this.name
  eventhub_name       = azurerm_eventhub.this.name
  resource_group_name = var.resource_group_name

  listen = each.value.listen
  send   = each.value.send
  manage = each.value.manage
}

variables.tf

variable "namespace_name" {
  description = "Globally unique name for the Event Hubs namespace (6-50 chars, alphanumeric and hyphens)."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$", var.namespace_name))
    error_message = "namespace_name must be 6-50 chars, start with a letter, and contain only letters, numbers, and hyphens."
  }
}

variable "event_hub_name" {
  description = "Name of the event hub (the partitioned log) created inside the namespace."
  type        = string
}

variable "resource_group_name" {
  description = "Name of the resource group that will contain the namespace and hub."
  type        = string
}

variable "location" {
  description = "Azure region for the namespace, e.g. centralindia."
  type        = string
}

variable "sku" {
  description = "Namespace SKU. Capture and Kafka require Standard or higher."
  type        = string
  default     = "Standard"

  validation {
    condition     = contains(["Basic", "Standard", "Premium"], var.sku)
    error_message = "sku must be one of: Basic, Standard, Premium."
  }
}

variable "capacity" {
  description = "Throughput Units (Basic/Standard) or Processing Units (Premium). 1-40 for Standard."
  type        = number
  default     = 1

  validation {
    condition     = var.capacity >= 1 && var.capacity <= 40
    error_message = "capacity must be between 1 and 40."
  }
}

variable "auto_inflate_enabled" {
  description = "Automatically scale Throughput Units under load. Standard SKU only."
  type        = bool
  default     = false
}

variable "maximum_throughput_units" {
  description = "Upper bound for Auto Inflate (1-40). Only used when auto_inflate_enabled is true."
  type        = number
  default     = 2

  validation {
    condition     = var.maximum_throughput_units >= 1 && var.maximum_throughput_units <= 40
    error_message = "maximum_throughput_units must be between 1 and 40."
  }
}

variable "partition_count" {
  description = "Number of partitions for the hub. IMMUTABLE after creation on Basic/Standard — size for peak parallelism."
  type        = number
  default     = 4

  validation {
    condition     = var.partition_count >= 1 && var.partition_count <= 32
    error_message = "partition_count must be between 1 and 32 (Standard). Cannot be changed after creation."
  }
}

variable "message_retention" {
  description = "Retention in days. Max 1 on Basic, 7 on Standard (Premium/Dedicated support more)."
  type        = number
  default     = 1

  validation {
    condition     = var.message_retention >= 1 && var.message_retention <= 90
    error_message = "message_retention must be between 1 and 90 days (SKU-dependent)."
  }
}

variable "public_network_access_enabled" {
  description = "Allow access over the public endpoint. Set false when fronting with Private Endpoint."
  type        = bool
  default     = true
}

variable "minimum_tls_version" {
  description = "Minimum TLS version accepted by the namespace."
  type        = string
  default     = "1.2"

  validation {
    condition     = contains(["1.0", "1.1", "1.2"], var.minimum_tls_version)
    error_message = "minimum_tls_version must be one of: 1.0, 1.1, 1.2."
  }
}

variable "local_authentication_enabled" {
  description = "Allow SAS-key (connection string) auth. Set false to force Entra ID / managed identity only."
  type        = bool
  default     = true
}

variable "network_ruleset" {
  description = "Optional namespace firewall. Deny by default and allow specific CIDRs / subnets."
  type = object({
    default_action                 = optional(string, "Deny")
    trusted_service_access_enabled = optional(bool, true)
    ip_rules                       = optional(list(string), [])
    subnet_ids                     = optional(list(string), [])
  })
  default = null
}

variable "capture" {
  description = "Optional Event Hubs Capture config that archives events to ADLS Gen2 / Blob as Avro."
  type = object({
    encoding            = optional(string, "Avro")
    interval_in_seconds = optional(number, 300)
    size_limit_in_bytes = optional(number, 314572800)
    skip_empty_archives = optional(bool, true)
    archive_name_format = optional(string, "{Namespace}/{EventHub}/{PartitionId}/{Year}/{Month}/{Day}/{Hour}/{Minute}/{Second}")
    blob_container_name = string
    storage_account_id  = string
  })
  default = null
}

variable "authorization_rules" {
  description = "Map of hub-scoped SAS rules. Grant only the rights each consumer/producer needs."
  type = map(object({
    listen = optional(bool, false)
    send   = optional(bool, false)
    manage = optional(bool, false)
  }))
  default = {}
}

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

outputs.tf

output "namespace_id" {
  description = "Resource ID of the Event Hubs namespace (use for RBAC role assignments / Private Endpoint)."
  value       = azurerm_eventhub_namespace.this.id
}

output "namespace_name" {
  description = "Name of the Event Hubs namespace."
  value       = azurerm_eventhub_namespace.this.name
}

output "eventhub_id" {
  description = "Resource ID of the event hub."
  value       = azurerm_eventhub.this.id
}

output "eventhub_name" {
  description = "Name of the event hub."
  value       = azurerm_eventhub.this.name
}

output "partition_ids" {
  description = "List of partition IDs for the hub (useful for partitioned consumers / checkpointing)."
  value       = azurerm_eventhub.this.partition_ids
}

output "namespace_default_hostname" {
  description = "Kafka/AMQP endpoint host for the namespace, e.g. <name>.servicebus.windows.net."
  value       = "${azurerm_eventhub_namespace.this.name}.servicebus.windows.net"
}

output "authorization_rule_ids" {
  description = "Map of authorization rule name to resource ID."
  value       = { for k, r in azurerm_eventhub_authorization_rule.app : k => r.id }
}

output "primary_connection_strings" {
  description = "Map of authorization rule name to its primary connection string."
  value       = { for k, r in azurerm_eventhub_authorization_rule.app : k => r.primary_connection_string }
  sensitive   = true
}

How to use it

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

  namespace_name      = "evhns-telemetry-prod-cin"
  event_hub_name      = "device-telemetry"
  resource_group_name = azurerm_resource_group.streaming.name
  location            = azurerm_resource_group.streaming.location

  sku                      = "Standard"
  capacity                 = 2
  auto_inflate_enabled     = true
  maximum_throughput_units = 10

  partition_count   = 8
  message_retention = 7

  # Lock down the namespace: force Entra ID auth and a private-only surface.
  public_network_access_enabled = false
  local_authentication_enabled  = true # keep SAS for the legacy producer below

  network_ruleset = {
    default_action = "Deny"
    subnet_ids     = [azurerm_subnet.ingest.id]
  }

  # Archive raw events to the lakehouse landing zone for replay/backfill.
  capture = {
    interval_in_seconds = 120
    blob_container_name = "eventhub-capture"
    storage_account_id  = azurerm_storage_account.lake.id
  }

  authorization_rules = {
    "iot-producer" = { send = true }
    "asa-reader"   = { listen = true }
  }

  tags = {
    workload    = "telemetry"
    environment = "prod"
    owner       = "data-platform"
  }
}

# Downstream: grant a Function App's managed identity data-plane access via RBAC
# (preferred over SAS) using the namespace_id output.
resource "azurerm_role_assignment" "fn_receiver" {
  scope                = module.event_hub.namespace_id
  role_definition_name = "Azure Event Hubs Data Receiver"
  principal_id         = azurerm_linux_function_app.processor.identity[0].principal_id
}

# Downstream: feed the legacy producer the scoped SAS connection string via Key Vault.
resource "azurerm_key_vault_secret" "producer_conn" {
  name         = "eventhub-iot-producer-conn"
  value        = module.event_hub.primary_connection_strings["iot-producer"]
  key_vault_id = azurerm_key_vault.app.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_hub/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-hub?ref=v1.0.0"
}

inputs = {
  namespace_name = "..."
  event_hub_name = "..."
  resource_group_name = "..."
  location = "..."
}

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

cd live/prod/event_hub && 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
namespace_name string Yes Globally unique namespace name (6-50 chars, validated).
event_hub_name string Yes Name of the event hub (partitioned log) inside the namespace.
resource_group_name string Yes Resource group for the namespace and hub.
location string Yes Azure region, e.g. centralindia.
sku string "Standard" No Basic, Standard, or Premium. Capture/Kafka need Standard+.
capacity number 1 No Throughput/Processing Units (1-40).
auto_inflate_enabled bool false No Auto-scale Throughput Units (Standard only).
maximum_throughput_units number 2 No Auto Inflate ceiling (1-40); used only when inflate is on.
partition_count number 4 No Partitions (1-32). Immutable after creation on Basic/Standard.
message_retention number 1 No Retention in days (SKU-capped: 1 Basic, 7 Standard).
public_network_access_enabled bool true No Allow the public endpoint; set false with Private Endpoint.
minimum_tls_version string "1.2" No Minimum accepted TLS version.
local_authentication_enabled bool true No Allow SAS-key auth; false forces Entra ID only.
network_ruleset object null No Namespace firewall (default action, IP/subnet allow-lists).
capture object null No Capture-to-Blob/ADLS config (Avro archive).
authorization_rules map(object) {} No Hub-scoped SAS rules with listen/send/manage rights.
tags map(string) {} No Tags applied to the namespace.

Outputs

Name Description
namespace_id Resource ID of the namespace (use for RBAC and Private Endpoint).
namespace_name Name of the namespace.
eventhub_id Resource ID of the event hub.
eventhub_name Name of the event hub.
partition_ids List of partition IDs for the hub.
namespace_default_hostname Kafka/AMQP host (<name>.servicebus.windows.net).
authorization_rule_ids Map of rule name to authorization rule resource ID.
primary_connection_strings Map of rule name to primary connection string (sensitive).

Enterprise scenario

A connected-vehicle platform ingests ~120k telemetry events/sec from field devices into evhns-telemetry-prod-cin. The module provisions an 8-partition device-telemetry hub on Standard with Auto Inflate to absorb rush-hour spikes, a Deny-by-default firewall that only admits the ingest subnet, and Capture writing 2-minute Avro batches into the ADLS landing zone. Stream Analytics reads the hub for real-time geofencing alerts using its managed identity (granted via namespace_id), while the data engineering team replays the captured Avro into Databricks for nightly model retraining — one module, two consumers, zero shared secrets on the hot path.

Best practices

TerraformAzureEvent HubModuleIaC
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