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
- You are standing up telemetry, IoT, or clickstream ingestion and want a partitioned, retention-bounded hub rather than hand-rolled queues.
- You need a Kafka-protocol endpoint without running Kafka — Event Hubs exposes a Kafka 1.0+ surface on the Standard SKU and above.
- You want Capture to automatically land raw events as Avro in ADLS Gen2 / Blob for replay, audit, or batch backfill.
- You are feeding Stream Analytics, Azure Functions, or Databricks and need stable connection strings or (preferably) a namespace ID for managed-identity RBAC.
- You want every hub to carry consistent naming, tags, retention, and a least-privilege SAS rule enforced by code review, not tribal knowledge.
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 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_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
- Size partitions for peak, not today.
partition_countis immutable on Basic/Standard and caps your consumer parallelism — a consumer group can have at most one active reader per partition. Bumping it later means a new hub and a cutover, so pick a number above your expected peak from the start. - Prefer Entra ID over connection strings. Set
local_authentication_enabled = falsewhere possible and grantAzure Event Hubs Data Sender/Data Receiverroles againstnamespace_id. When you must keep SAS (legacy SDKs), scope rules at the hub with the single right needed (sendORlisten, nevermanage) and stash the string in Key Vault, never in code. - Lock the network down. Combine
public_network_access_enabled = falsewith a Private Endpoint, or usenetwork_rulesetwithdefault_action = "Deny"plustrusted_service_access_enabled = trueso first-party services (Stream Analytics, Capture) still work. - Use Capture for cheap durable replay. Capture to ADLS Gen2 is far cheaper than extending
message_retention, gives you an immutable Avro audit trail, and decouples batch consumers from the hot stream. Keepskip_empty_archives = trueto avoid littering empty blobs. - Right-size throughput and turn on Auto Inflate. Each Standard TU is ~1 MB/s ingress; start at
capacity = 1-2and letauto_inflate_enabledscale up under load rather than over-provisioning a flat high TU count and paying for idle headroom. - Name and tag consistently. Use the CAF prefixes
evhns-(namespace) and a clear hub name, and applyworkload/environment/ownertags so cost shows up correctly in the streaming cost centre.