Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Event Hubs Namespace Terraform Module

A composite module that provisions an Azure Event Hubs namespace together with its owned event hubs and consumer groups, hardened by default and targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Version Type Resources


🧩 Overview

This module manages an Azure Event Hubs namespace as a single owned unit:

  • 📛 A namespace (azurerm_eventhub_namespace) — the keystone, with SKU, capacity, auto-inflate, TLS floor, identity, and firewall.
  • 📨 A keyed map of event hubs (azurerm_eventhub) — partitions, retention, status, and optional capture-to-storage.
  • 👥 A keyed map of consumer groups (azurerm_eventhub_consumer_group) — each attached to one owned event hub by its map key.
  • 🔒 A locked-down posture — public access off, SAS local auth off, TLS 1.2 floor, deny-by-default firewall when supplied.

💡 Why it matters: Event Hubs is the ingestion front door for streaming and telemetry pipelines. Getting the namespace posture right once — private, Entra-only, TLS-floored — means every producer and consumer inherits a safe baseline instead of re-deriving it. Modeling hubs and consumer groups as keyed maps keeps a busy topology reviewable and free of re-indexing churn.

❤️ Support this project

If this module saves you time, please consider supporting it:

🗺️ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  ehns["terraform-azurerm-eventhub-namespace"]
  sa["terraform-azurerm-storage-account"]
  ra["terraform-azurerm-role-assignments"]
  pe["terraform-azurerm-private-endpoint"]
  prod["Producers (apps / IoT / diagnostics)"]
  cons["Consumers (Functions / Stream Analytics / Databricks)"]
  nsauth["terraform-azurerm-eventhub-namespace-authorization-rule"]
  ehauth["terraform-azurerm-eventhub-authorization-rule"]
  cmk["terraform-azurerm-eventhub-namespace-customer-managed-key"]
  dr["terraform-azurerm-eventhub-namespace-disaster-recovery-config"]
  schema["terraform-azurerm-eventhub-namespace-schema-group"]

  rg -->|"resource_group_name"| ehns
  prod -->|"send events"| ehns
  ehns -->|"deliver events"| cons
  ehns -->|"capture to blob"| sa
  ra -->|"Data Sender / Receiver at scope"| ehns
  pe -->|"private connectivity (Premium)"| ehns
  ehns -->|"namespace SAS rule"| nsauth
  ehns -->|"per-hub SAS rule"| ehauth
  ehns -->|"CMK encryption"| cmk
  ehns -->|"geo-DR pairing"| dr
  ehns -->|"schema registry"| schema

  classDef self fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#001b33,color:#ffffff;
  classDef ext fill:#F2F2F2,stroke:#B0B0B0,color:#1a1a1a;

  class ehns self;
  class sa keystone;
  class rg,ra,pe,prod,cons,nsauth,ehauth,cmk,dr,schema ext;
Loading

The namespace consumes a resource group by name, optionally captures to a storage account, and is fronted by role assignments and a private endpoint — all owned by their own sibling modules and wired here by id / name. A private endpoint needs Standard or above; it is unsupported only on Basic.

🧬 What this module builds

flowchart TD
  subgraph module["terraform-azurerm-eventhub-namespace"]
    ns["azurerm_eventhub_namespace.this"]
    eh["azurerm_eventhub.this (for_each)"]
    cg["azurerm_eventhub_consumer_group.this (for_each)"]
  end

  ns -->|"namespace_id"| eh
  eh -->|"eventhub_name (by key)"| cg
  ns -->|"namespace_name"| cg

  classDef self fill:#0078D4,stroke:#004578,color:#ffffff;
  classDef keystone fill:#004578,stroke:#001b33,color:#ffffff;

  class eh,cg self;
  class ns keystone;
Loading

Resource inventory

Resource Cardinality Role
azurerm_eventhub_namespace.this 1 (keystone) The namespace and its posture.
azurerm_eventhub.this for_each map Event hubs (partitions, retention, capture).
azurerm_eventhub_consumer_group.this for_each map Consumer groups, each bound to one hub by key.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
azurerm provider ~> 4.0
Provider block None in this module — the caller configures provider "azurerm" { features {} }, auth, and subscription.

Schema notes that bite (verified against the live provider schema):

  • ⚠️ name, resource_group_name, and location on the namespace are immutable — changing any of them forces replacement of the namespace and every child hub and consumer group.
  • ⚠️ dedicated_cluster_id is force-new — moving a namespace into or out of a dedicated cluster replaces it.
  • 🔴 azurerm_eventhub.partition_count is NOT force-new, and that is worse than if it were. Changing it on a shared Basic or Standard namespace produces a clean in-place plan; the provider then performs a live read of the namespace during apply and refuses with "partition_count cannot be changed on shared namespaces unless the namespace sku is Premium". So the plan looks fine and the apply stops. On Premium or in a dedicated cluster it can be increased in place; a decrease is refused on every tier. Increasing it also changes how partition keys map to partitions, so event ordering across the stream changes with it.
  • 🔴 partition_count and message_retention are validated by the provider at 1-1024 and 1-90 on every tier, while its own error messages describe narrower rules. Azure's real ceilings are 32 partitions and 1 day on Basic, 32 and 7 days on Standard, 100 and 90 days on Premium, and 1024 and 90 days in a dedicated cluster. This module enforces the tier ceiling so the mismatch fails at terraform validate rather than at apply.
  • 🔴 capacity has no validation in the provider at all. Basic and Standard take 1-40 throughput units; Premium takes processing units in the fixed sizes 1, 2, 4, 6, 8, 10, 12 and 16 only. Both bounds are enforced here.
  • ℹ️ auto_inflate_enabled and capture_description require Standard or above; so does network_rulesets, for which the provider raises "network_rulesets cannot be used when the SKU is basic" from its Create and Update functions. All three are apply-time errors in the provider and validate-time ones here.
  • 🔴 The namespace's public_network_access_enabled and network_rulesets.public_network_access_enabled must agree, or the provider aborts the apply with "the value of public network access of namespace should be the same as of the network rulesets". The nested field defaults to true inside the provider while this module's namespace default is false, so a firewall block written the obvious way used to fail every time. The module now renders the nested value from the namespace value when it is unset, and refuses an explicit mismatch offline.
  • 🔴 network_rulesets is Optional and Computed. Removing the block from a configuration does not remove the firewall — the provider adopts whatever Azure reports and shows no diff. Set default_action = "Allow" explicitly to open the namespace back up.
  • 🔴 Changing sku to or from Premium forces replacement of the namespace, and with it every hub and consumer group. The rule lives in the provider's CustomizeDiff, not its schema, so nothing in the binary schema shows it. Basic to Standard and back is in place.
  • ⚠️ On the pinned 4.x line minimum_tls_version accepts 1.0, 1.1 and 1.2; the 5.0 schema — which is what the registry documentation describes — accepts only 1.2. The module accepts all three and reports a below-floor choice, because refusing would leave an existing namespace unmanageable and would block terraform destroy.
  • ℹ️ maximum_throughput_units is only honored when auto_inflate_enabled = true.
  • ℹ️ Private endpoints need Standard or above, not Premium. Microsoft states only that Private Link "isn't supported in the basic tier", so a Standard namespace can use both a private endpoint and network_rulesets. Up to 120 private endpoints per namespace. Customer-managed keys are the Premium/Dedicated feature, and this module does not expose them.
  • ⚠️ Disabling public access blocks traffic at the application layer, not the TCP layer — so nslookup and a bare TCP connection against the public endpoint still succeed on a namespace whose public access is off. Do not use either as evidence that the private endpoint is not in effect.
  • 🔴 message_retention and a per-hub retention_description are not merely alternatives — the provider marks them ExactlyOneOf, so exactly one of the two is REQUIRED on every hub. A hub defined with only a name and a partition count cannot terraform validate; there is no "accept the service default" option. retention_description also carries a second ExactlyOneOf between retention_time_in_hours and tombstone_retention_time_in_hours. Neither constraint appears in the binary schema.
  • ⚠️ capture_description.destination.storage_authentication_type defaults to "StorageSAS" — a value the API does not define, hardcoded by the provider to match the portal. Capture therefore authenticates to storage with a shared key unless you set "SystemAssigned" or "UserAssigned". The capture_uses_shared_key_event_hubs output names every hub in that state.
  • ⚠️ azurerm_eventhub and azurerm_eventhub_consumer_group support no tags at all. The namespace tag is the only one there is; it is not inherited.

🔑 Required Azure RBAC Roles / Permissions

Least-privilege, at the smallest scope that works:

  • Management plane (provisioning): Contributor on the target resource group, or a custom role granting Microsoft.EventHub/namespaces/*, Microsoft.EventHub/namespaces/eventhubs/*, and Microsoft.EventHub/namespaces/eventhubs/consumergroups/*, scoped to the resource group.
  • Data plane (send/receive at runtime): assign to producer/consumer identities, not to the deploying principal —
    • Azure Event Hubs Data Owner — full data-plane management.
    • Azure Event Hubs Data Sender — publish events.
    • Azure Event Hubs Data Receiver — consume events.
  • Capture to storage: the namespace identity (or the identity named in capture_description.destination.storage_authentication_id) needs Storage Blob Data Contributor on the target storage account. Without one of those, capture falls back to the provider's default of StorageSAS and authenticates with a shared key.

🔴 Plan access is credential access on this resource. The provider declares six computed, sensitive attributes on azurerm_eventhub_namespace — the primary and secondary keys, their connection strings, and two alias connection strings — and its Read function fetches them from the RootManageSharedAccessKey rule on every refresh. Terraform reads computed attributes whenever it refreshes, so anyone who can run terraform plan against a configuration using this module thereby retrieves keys that grant Manage over the whole namespace, and those values are written to the state file in plaintext.

Setting local_authentication_enabled = false does not change this. It stops SAS being accepted at the data plane; it does not stop the keys existing or being read. Treat plan rights on this module as equivalent to namespace-owner rights, grant the data-plane roles above to workload identities rather than distributing keys, and keep state in an encrypted, access-controlled backend — marking an output sensitive redacts plan output, it does not encrypt state.

Azure Prerequisites

  • An existing resource group in a supported US Azure region (e.g. eastus, eastus2, westus2, centralus).
  • The Microsoft.EventHub resource provider registered on the target subscription.
  • For capture: an existing storage account and blob container the namespace identity can write to.
  • For a private endpoint: Standard or above (Private Link is unsupported on Basic only), plus the subnet and private DNS zone — privatelink.servicebus.windows.net — that sibling modules provision.
  • For retention_description (log compaction and delete policies) and customer-managed keys: a Premium namespace or a Dedicated cluster.
  • The caller configures the provider "azurerm" { features {} } block, authentication, and subscription; this module declares none of these.

📁 Module Structure

terraform-azurerm-eventhub-namespace/
├── providers.tf   # required_version + azurerm ~> 4.0 pin; no provider block
├── variables.tf   # deeply-typed object() schemas + tags/timeouts tail
├── main.tf        # keystone namespace + for_each hubs & consumer groups
├── outputs.tf     # id first, then name, then child id/name maps
├── README.md      # this document
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT, Copyright (c) 2026 Casey Wood
└── .gitignore     # canonical library ignore set

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-telemetry-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  event_hubs = {
    telemetry = {
      name              = "telemetry"
      partition_count   = 4
      message_retention = 7
    }
  }
}

ℹ️ The caller owns the provider, authentication, subscription selection, and the mandatory features {} block. The empty-posture call above is already private (no public access), Entra-only (SAS off), and TLS 1.2-floored.

🔌 Cross-Module Contract

Consumes

Input Type Source
resource_group_name string terraform-azurerm-resource-group output name
location string caller / resource group location
event_hubs[*].capture_description.destination.storage_account_id string terraform-azurerm-storage-account output id
network_rulesets.virtual_network_rule[*].subnet_id string terraform-azurerm-virtual-network subnet ID
identity.identity_ids[*] string terraform-azurerm-user-assigned-identity output id

Emits

Output Description Consumed by
id Namespace Resource ID (first) eventhub-namespace-customer-managed-key, eventhub-namespace-schema-group, role assignments, private endpoint, diagnostics
name Namespace name eventhub-namespace-authorization-rule, eventhub-authorization-rule, eventhub-namespace-disaster-recovery-config, diagnostics, tagging conventions
resource_group_name Resource group holding the namespace siblings that take a resource group name rather than an id
namespace_fqdn <name>.servicebus.windows.net producer/consumer connection configuration
sku Pricing tier siblings whose own availability is tier-gated
eventhub_ids map: key → event hub ID role assignments at hub scope, consumers
eventhub_names map: key → event hub name producer/consumer configuration, eventhub-authorization-rule
eventhub_partition_ids map: key → set of partition IDs partitioned consumers
eventhub_consumer_group_ids map: key → consumer group ID consumer configuration
identity_principal_id system-assigned principal ID (or null) RBAC grants to the namespace identity, and to the capture storage account

The remaining outputs are advisory rather than wiring — posture and quota flags a composition asserts on rather than feeds forward. They are listed in full under 🧾 Outputs.

📚 Example Library

1 · Minimal, hardened namespace
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-minimal-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"
}

🔒 The empty call ships public_network_access_enabled = false, local_authentication_enabled = false, and minimum_tls_version = "1.2". No event hubs are created until you add them.

2 · Namespace with event hubs
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-ingest-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  event_hubs = {
    orders = {
      name              = "orders"
      partition_count   = 8
      message_retention = 7
    }
    clicks = {
      name              = "clicks"
      partition_count   = 4
      message_retention = 3
    }
  }
}

💡 Map keys (orders, clicks) are the stable for_each identifiers. Renaming a key replaces only that hub; the others are untouched.

3 · Consumer groups bound to a hub by key
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-cg-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  event_hubs = {
    orders = {
      name              = "orders"
      partition_count   = 8
      message_retention = 7
    }
  }

  consumer_groups = {
    billing = {
      name         = "billing-processor"
      eventhub_key = "orders"
    }
    analytics = {
      name          = "analytics-stream"
      eventhub_key  = "orders"
      user_metadata = "owned by data-platform team"
    }
  }
}

ℹ️ eventhub_key must match a key in event_hubs. The consumer group resolves its eventhub_name from that hub, so the dependency ordering is implicit — no depends_on needed.

4 · Capture to blob storage (Avro)
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-capture-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"
  sku                 = "Standard"

  event_hubs = {
    audit = {
      name              = "audit"
      partition_count   = 4
      message_retention = 7
      capture_description = {
        enabled             = true
        encoding            = "Avro"
        interval_in_seconds = 300
        size_limit_in_bytes = 314572800
        skip_empty_archives = true
        destination = {
          name                = "EventHubArchive.AzureBlockBlob"
          archive_name_format = "{Namespace}/{EventHub}/{PartitionId}/{Year}/{Month}/{Day}/{Hour}/{Minute}/{Second}"
          blob_container_name = "eh-capture"
          storage_account_id  = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-streaming-prod/providers/Microsoft.Storage/storageAccounts/stcaptureprod"
        }
      }
    }
  }
}

⚠️ Capture requires Standard or above. The namespace (or the identity named in storage_authentication_id) needs Storage Blob Data Contributor on the target account.

5 · Capture authenticated by a managed identity
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-capauth-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  identity = {
    type = "SystemAssigned"
  }

  event_hubs = {
    audit = {
      name              = "audit"
      partition_count   = 2
      message_retention = 7
      capture_description = {
        enabled  = true
        encoding = "AvroDeflate"
        destination = {
          name                        = "EventHubArchive.AzureBlockBlob"
          archive_name_format         = "{Namespace}/{EventHub}/{PartitionId}/{Year}/{Month}/{Day}/{Hour}/{Minute}/{Second}"
          blob_container_name         = "eh-capture"
          storage_account_id          = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-streaming-prod/providers/Microsoft.Storage/storageAccounts/stcaptureprod"
          storage_authentication_type = "SystemAssigned"
        }
      }
    }
  }
}

🔒 Prefer managed-identity capture over storage account keys. The namespace's system-assigned identity writes the archives; grant it Storage Blob Data Contributor on the account.

6 · Auto-inflate on Standard
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                     = "ehns-inflate-prod"
  resource_group_name      = "rg-streaming-prod"
  location                 = "eastus2"
  sku                      = "Standard"
  capacity                 = 2
  auto_inflate_enabled     = true
  maximum_throughput_units = 10
}

💡 maximum_throughput_units is only honored when auto_inflate_enabled = true; leave it null otherwise. Auto-inflate is unavailable on Basic.

7 · Namespace firewall (IP + VNet rules)
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                          = "ehns-fw-prod"
  resource_group_name           = "rg-streaming-prod"
  location                      = "eastus2"
  public_network_access_enabled = true

  network_rulesets = {
    default_action                 = "Deny"
    trusted_service_access_enabled = true
    ip_rule = [
      { ip_mask = "203.0.113.0/24" },
      { ip_mask = "198.51.100.10" },
    ]
    virtual_network_rule = [
      {
        subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/virtualNetworks/vnet-prod/subnets/snet-apps"
      },
    ]
  }
}

🔒 default_action defaults to "Deny" — only the listed IP ranges and subnets are admitted. Set public_network_access_enabled = true so the firewall has a public surface to guard.

8 · System-assigned identity
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-sysid-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  identity = {
    type = "SystemAssigned"
  }
}

💡 The namespace's own principal (exposed as identity_principal_id) is what you grant storage and key-vault access to for capture and customer-managed keys.

9 · User-assigned identity
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-uaid-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  identity = {
    type = "UserAssigned"
    identity_ids = [
      "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-identity-prod/providers/Microsoft.ManagedIdentity/userAssignedIdentities/uami-eventhub",
    ]
  }
}

ℹ️ identity_ids is required whenever type includes UserAssigned. Use a user-assigned identity when the same principal must be reused across resources or pre-provisioned with role grants.

10 · Premium namespace with per-hub retention policy
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-premium-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"
  sku                 = "Premium"
  capacity            = 1

  event_hubs = {
    changelog = {
      name            = "changelog"
      partition_count = 8
      retention_description = {
        cleanup_policy                    = "Compact"
        tombstone_retention_time_in_hours = 48
      }
    }
    events = {
      name            = "events"
      partition_count = 4
      retention_description = {
        cleanup_policy          = "Delete"
        retention_time_in_hours = 168
      }
    }
  }
}

⚠️ retention_description (log compaction / delete policies) is a Premium/Dedicated feature. On Premium, partition_count can be increased in place but never decreased.

11 · Basic tier (cost-minimal)
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-basic-dev"
  resource_group_name = "rg-streaming-dev"
  location            = "eastus"
  sku                 = "Basic"

  event_hubs = {
    events = {
      name              = "events"
      partition_count   = 2
      message_retention = 1
    }
  }
}

ℹ️ Basic caps retention at 1 day, offers no capture or auto-inflate, and supports only the implicit $Default consumer group — do not add consumer_groups on a Basic namespace.

12 · Strict security variant
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                          = "ehns-locked-prod"
  resource_group_name           = "rg-streaming-prod"
  location                      = "eastus2"
  sku                           = "Standard"
  local_authentication_enabled  = false
  public_network_access_enabled = false
  minimum_tls_version           = "1.2"

  identity = { type = "SystemAssigned" }

  event_hubs = {
    secure = {
      name              = "secure"
      partition_count   = 4
      message_retention = 7
      status            = "Active"
    }
  }
}

🔒 Every locked-down default stated explicitly for auditability: SAS local auth off, public access off, TLS 1.2 floor, and a managed identity in place of embedded credentials.

13 · for_each at scale (many hubs and consumer groups)
locals {
  domains = ["orders", "payments", "shipping", "inventory"]
}

module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-platform-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  event_hubs = {
    for d in local.domains : d => {
      name              = d
      partition_count   = 4
      message_retention = 7
    }
  }

  consumer_groups = merge([
    for d in local.domains : {
      "${d}-audit"     = { name = "${d}-audit", eventhub_key = d }
      "${d}-analytics" = { name = "${d}-analytics", eventhub_key = d }
    }
  ]...)
}

💡 Keys stay stable as the domain list grows, so adding a fifth domain never re-indexes the first four hubs or their consumer groups.

14 · Custom timeouts and tags
module "eventhub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"

  name                = "ehns-tagged-prod"
  resource_group_name = "rg-streaming-prod"
  location            = "eastus2"

  tags = {
    environment = "prod"
    owner       = "data-platform"
    cost_center = "CC-4021"
  }

  timeouts = {
    create = "30m"
    delete = "30m"
  }
}

ℹ️ tags apply to the namespace. timeouts values use Go duration strings such as "30m".

15 · 🏗️ End-to-end composition

Wire a resource group, a capture storage account, this namespace, and a Data Sender role assignment for a producer identity.

provider "azurerm" {
  features {}
}

module "resource_group" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-streaming-prod"
  location = "eastus2"
}

module "capture_storage" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
  name                = "stcaptureprod0421"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location

  containers = {
    capture = { name = "eh-capture" }
  }
}

module "eventhub_namespace" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-eventhub-namespace.git?ref=v1.0.0"
  name                = "ehns-platform-prod"
  resource_group_name = module.resource_group.name
  location            = module.resource_group.location
  sku                 = "Standard"

  identity = { type = "SystemAssigned" }

  event_hubs = {
    telemetry = {
      name              = "telemetry"
      partition_count   = 8
      message_retention = 7
      capture_description = {
        enabled  = true
        encoding = "Avro"
        destination = {
          name                        = "EventHubArchive.AzureBlockBlob"
          archive_name_format         = "{Namespace}/{EventHub}/{PartitionId}/{Year}/{Month}/{Day}/{Hour}/{Minute}/{Second}"
          blob_container_name         = "eh-capture"
          storage_account_id          = module.capture_storage.id
          storage_authentication_type = "SystemAssigned"
        }
      }
    }
  }

  consumer_groups = {
    stream = { name = "stream-processor", eventhub_key = "telemetry" }
  }
}

module "producer_grant" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.eventhub_namespace.id

  role_assignments = {
    producer_sender = {
      scope                = module.eventhub_namespace.id
      role_definition_name = "Azure Event Hubs Data Sender"
      principal_id         = var.producer_principal_id
    }
  }
}

🏗️ Outputs feed inputs end to end: the resource group's name/location drive both data resources, the storage account's id becomes the capture destination, and the namespace's id is the scope for the Data Sender grant. Grant the namespace identity Storage Blob Data Contributor on the capture account for archives to succeed.

📥 Inputs

Identity & placement — name, resource_group_name, location (all required, all immutable).

Namespace configuration — sku ("Standard"), capacity (1), auto_inflate_enabled (false), maximum_throughput_units (null), dedicated_cluster_id (null).

Secure posture — local_authentication_enabled (false), public_network_access_enabled (false), minimum_tls_version ("1.2"), identity (null), network_rulesets (null).

Child collections — event_hubs ({}), consumer_groups ({}).

Universal tail — tags ({}), timeouts (null).

Full object() schemas
variable "identity" {
  type = object({
    type         = string
    identity_ids = optional(list(string))
  })
  default = null
}

variable "network_rulesets" {
  type = object({
    default_action                 = optional(string, "Deny")
    public_network_access_enabled  = optional(bool)
    trusted_service_access_enabled = optional(bool)
    ip_rule = optional(list(object({
      ip_mask = string
      action  = optional(string, "Allow")
    })), [])
    virtual_network_rule = optional(set(object({
      subnet_id                                       = string
      ignore_missing_virtual_network_service_endpoint = optional(bool)
    })), [])
  })
  default = null
}

variable "event_hubs" {
  type = map(object({
    name              = string
    partition_count   = number
    message_retention = optional(number)
    status            = optional(string)
    capture_description = optional(object({
      enabled             = bool
      encoding            = string
      interval_in_seconds = optional(number)
      size_limit_in_bytes = optional(number)
      skip_empty_archives = optional(bool)
      destination = object({
        name                        = string
        archive_name_format         = string
        blob_container_name         = string
        storage_account_id          = string
        storage_authentication_id   = optional(string)
        storage_authentication_type = optional(string)
      })
    }))
    retention_description = optional(object({
      cleanup_policy                    = string
      retention_time_in_hours           = optional(number)
      tombstone_retention_time_in_hours = optional(number)
    }))
  }))
  default = {}
}

variable "consumer_groups" {
  type = map(object({
    name          = string
    eventhub_key  = string
    user_metadata = optional(string)
  }))
  default = {}
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

Closed value sets enforced here, all case-sensitive because the provider compares that way: sku ∈ {Basic, Standard, Premium}, minimum_tls_version ∈ {1.0, 1.1, 1.2} (the pinned 4.x set — 5.0 keeps only 1.2), network_rulesets.default_action ∈ {Deny, Allow}, network_rulesets.ip_rule[*].action ∈ {Allow}, identity.type ∈ {SystemAssigned, UserAssigned, "SystemAssigned, UserAssigned"}, per-hub status ∈ {Active, Disabled, SendDisabled}, capture_description.encoding ∈ {Avro, AvroDeflate}, capture_description.destination.name ∈ {EventHubArchive.AzureBlockBlob}, storage_authentication_type ∈ {StorageSAS, SystemAssigned, UserAssigned} and retention_description.cleanup_policy ∈ {Delete, Compact}.

Beyond the enums, the module mirrors the provider's regexes for the namespace, event hub and consumer group names; both invisible ExactlyOneOf pairings; the numeric ranges the provider carries; the tier ceilings the provider does not carry (partitions, retention, processing-unit sizes, capture, auto-inflate, the firewall, consumer groups); and the two cross-field rules the provider raises at apply — the identity pairing and the public-access agreement between the namespace and its firewall.

🧾 Outputs

Output Description Notes
id Namespace Resource ID emitted first; the scope for data-plane role assignments
name Namespace name globally unique; force-new
resource_group_name Resource group holding the namespace consumer groups take a name, not an id
location Azure region, in the canonical form Azure uses. Read from the resource, not var.location.
namespace_fqdn <name>.servicebus.windows.net derived, not read — the provider exports no hostname; public-cloud suffix only
sku Pricing tier gates retention, partitions, hubs, consumer groups, capture, auto-inflate and the firewall
capacity Throughput units, or processing units on Premium billed whether used
billed_whether_used Constant true units are charged on an idle namespace
auto_inflate_can_raise_billed_capacity_silently Auto-inflate is on it scales up only, and surfaces later as drift
maximum_throughput_units_is_inert A max-TU bound with auto-inflate off accepted, bounds nothing
public_network_access_enabled Public reachability, stated positively known at plan time — usable for count
local_authentication_enabled SAS accepted as well as Entra ID module default is false; provider default is true
minimum_tls_version TLS floor the data plane accepts read from the resource
minimum_tls_version_below_current_baseline 1.0 or 1.1 configured still legal on 4.x; removed in 5.0. Reported, not refused
plan_access_is_credential_access Constant true whoever can plan retrieves the root keys — see the RBAC section
identity_principal_id System-assigned principal ID null when no identity
identity_tenant_id Managed identity tenant ID null when no identity
network_rulesets_configured A firewall block was supplied reflects the configuration, not the namespace
removing_network_rulesets_does_not_remove_the_firewall Constant true the block is Optional and Computed
firewall_default_action Deny, Allow, or null null when no firewall is configured
firewall_rule_count IP rules + virtual-network rules Azure allows 128 of each
trusted_service_access_enabled Trusted Microsoft services bypass the firewall membership of that set is Azure's, not yours
eventhub_ids map: key → event hub ID empty when no hubs
eventhub_names map: key → event hub name several resources take names, not ids
eventhub_partition_ids map: key → set of partition IDs assigned by the service
eventhub_count number of event hubs —
eventhub_count_exceeds_tier_limit more hubs than the tier allows 10 on Basic/Standard, 100 per PU on Premium. Reported, not refused
eventhub_partition_count_by_key map: key → configured partition count the setting whose plan lies about its apply
total_partition_count sum across all hubs Premium caps 200 partitions per processing unit namespace-wide
capture_enabled_event_hubs keys of hubs with capture on Standard, also the set carrying a per-hour charge
capture_uses_shared_key_event_hubs keys whose capture uses a shared key the provider's default; set an identity type to empty this list
eventhub_consumer_group_ids map: key → consumer group ID every identifying field is force-new
consumer_group_count_by_event_hub map: hub key → group count compare against 20 (Standard) / 100 (Premium)
emits_no_shared_access_keys Constant true —

🔒 No connection strings or access keys are emitted. Re-emitting a secret copies it into every consuming configuration's state as well, for no benefit — and a module caller cannot reach the underlying resource's attributes to emit them anyway. Prefer Entra ID (RBAC) data-plane access with the roles named above; where a SAS connection string is genuinely unavoidable, create a dedicated authorization rule with a sibling module scoped to a single event hub, rather than reusing the namespace's root key.

🧠 Architecture Notes

  • One keystone, keyed children. azurerm_eventhub_namespace.this is the single owned resource; hubs and consumer groups are for_each over keyed maps, so a key rename or removal touches only that record and never re-indexes the rest.
  • Consumer-to-hub binding is by map key. Each consumer group names its owning hub through eventhub_key, which is resolved to the hub's name inside main.tf. That reference makes the create/destroy ordering implicit — Terraform builds the namespace, then the hub, then the consumer group, with no depends_on.
  • Force-new realities. Changing name, resource_group_name or location replaces the whole namespace and its children, and so does moving sku to or from Premium — that last one from a CustomizeDiff the schema does not show. A hub's partition_count is the opposite trap: it is not force-new, so a change plans as an in-place update that the provider then refuses at apply on any shared Basic or Standard namespace, after a live read. Treat all of these as design-time decisions.
  • Every hub needs a retention setting. The provider's ExactlyOneOf between message_retention and retention_description is invisible in the binary schema and means a hub with neither cannot terraform validate at all. There is no service default to fall back on.
  • The firewall does not come off when you delete the block. network_rulesets is Optional and Computed, so an absent block reads as adopt whatever exists. Removing it produces an empty plan and leaves the namespace firewalled exactly as before. Set default_action = "Allow" to open it back up.
  • Two public-access switches that must agree. The namespace's own public_network_access_enabled and the nested one inside network_rulesets are compared by the provider, which aborts the apply when they differ. Their defaults disagree — false here, true inside the provider's nested block — so main.tf renders the nested value from the namespace value whenever the caller leaves it unset, and variables.tf refuses an explicit contradiction offline.
  • Capture authenticates with a shared key by default. storage_authentication_type defaults to "StorageSAS", a value the API does not define. Set "SystemAssigned" or "UserAssigned" and grant that identity Storage Blob Data Contributor on the destination account; capture_uses_shared_key_event_hubs lists every hub still on the default.
  • Auto-inflate scales one way. It raises throughput units under load and never lowers them, so a spike raises the bill permanently. Terraform does not see it happen — it surfaces later as drift on capacity, and the next plan proposes undoing the scale-up that production may still need.
  • Tier gates. Auto-inflate and capture need Standard+, private endpoints and retention_description need Premium/Dedicated. The module renders whatever you pass and lets the provider enforce the tier rule, so match the SKU to the features you enable.
  • Optional blocks render only when supplied. identity, network_rulesets, per-hub capture_description/retention_description, and timeouts are all dynamic blocks gated on a non-null value, with try(x, null) on every optional inner field — an omitted key renders as absent, not as an error.
  • features {} dependence. This module carries no provider block; the caller's provider "azurerm" { features {} } is required for initialization. Its absence in a root module is the usual cause of an init failure.

🧱 Design Principles

The empty call produces the hardened resource; each relaxation is an explicit opt-out:

Concern Secure default Opt-out
Public network access public_network_access_enabled = false set to true
Local (SAS) authentication local_authentication_enabled = false set to true
TLS floor minimum_tls_version = "1.2" set "1.1" / "1.0"
Firewall default action network_rulesets.default_action = "Deny" set to "Allow"
Identity prefer a managed identity {} over embedded credentials omit identity
Secret outputs none emitted read from the resource at the root module, mark sensitive

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module with ?ref=v1.0.0 — never track a branch.
  • This is a plan-only / static-analysis workflow. A human runs terraform plan and terraform apply from CI against real credentials; the module authoring process never applies.

🧪 Testing

The offline proof gate that ships with this module:

  • terraform init -backend=false — resolve the pinned provider without a backend.
  • terraform validate — prove the configuration is internally consistent and type-correct against the pinned provider schema; the deeply-typed object() inputs surface a malformed call here, before any Azure API call.
  • terraform fmt -check — enforce canonical formatting.

What the gate does not cover: validate and fmt never contact Azure. Tier-specific rejections (e.g. capture on Basic), RBAC, quota, and name-uniqueness are only exercised by terraform plan/apply against a live subscription, which a human runs from CI.

💬 Example Output

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

id                                              = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-streaming-prod/providers/Microsoft.EventHub/namespaces/ehns-platform-prod"
name                                            = "ehns-platform-prod"
resource_group_name                             = "rg-streaming-prod"
location                                        = "eastus2"
namespace_fqdn                                  = "ehns-platform-prod.servicebus.windows.net"
sku                                             = "Standard"
capacity                                        = 4
billed_whether_used                             = true
auto_inflate_can_raise_billed_capacity_silently = false
maximum_throughput_units_is_inert               = false
public_network_access_enabled                   = false
local_authentication_enabled                    = false
minimum_tls_version                             = "1.2"
minimum_tls_version_below_current_baseline      = false
plan_access_is_credential_access                = true
network_rulesets_configured                     = false
firewall_default_action                         = null
firewall_rule_count                             = 0
eventhub_count                                  = 1
eventhub_count_exceeds_tier_limit               = false
total_partition_count                           = 8
capture_enabled_event_hubs                      = ["telemetry"]
capture_uses_shared_key_event_hubs              = []
consumer_group_count_by_event_hub               = {
  "telemetry" = 1
}
eventhub_ids                = {
  "telemetry" = "/subscriptions/.../namespaces/ehns-platform-prod/eventhubs/telemetry"
}
eventhub_names              = {
  "telemetry" = "telemetry"
}
eventhub_partition_ids      = {
  "telemetry" = ["0", "1", "2", "3", "4", "5", "6", "7"]
}
eventhub_consumer_group_ids = {
  "stream" = "/subscriptions/.../eventhubs/telemetry/consumergroups/stream-processor"
}
identity_principal_id       = "11111111-2222-3333-4444-555555555555"
identity_tenant_id          = "66666666-7777-8888-9999-000000000000"

🔍 Troubleshooting

Symptom Cause Fix
Provider configuration not present / init fails No features {} block in the root module Add provider "azurerm" { features {} } to the caller.
Plan wants to replace the namespace Changed name, resource_group_name, or location (all force-new) Keep them stable, or accept replacement deliberately.
Capture rejected at plan/apply capture_description used on a Basic namespace Set sku = "Standard" or higher.
partition_count cannot be changed on shared namespaces unless the namespace sku is Premium at apply, after a clean plan partition_count is not force-new, so the change plans as an in-place update; the provider then reads the namespace live and refuses Provision the final partition count up front, or move to Premium or a dedicated cluster (increase-only). A decrease is refused on every tier.
one of message_retention,retention_description must be specified A hub set neither. The provider marks them ExactlyOneOf, and the binary schema does not show it Set exactly one on every hub. There is no service default.
the value of public network access of namespace should be the same as of the network rulesets The namespace's public_network_access_enabled and the nested one inside network_rulesets disagree Leave the nested value unset and this module renders the namespace value for you; the module also refuses an explicit mismatch at terraform validate.
Deleting the network_rulesets block produces an empty plan and the firewall stays The block is Optional and Computed, so an absent block means "adopt what exists" Keep the block and set default_action = "Allow".
nslookup still resolves the public endpoint with public access disabled Traffic is blocked at the application layer, not at TCP Expected. Verify the private endpoint by the address the name resolves to inside the VNet, not by whether the name resolves at all.
Capacity keeps rising and the plan wants to lower it Auto-inflate scaled the namespace up under load and never scales back down Raise the configured capacity to match, or accept that applying will undo the scale-up.
A Premium namespace refuses capacity = 3 or capacity = 20 Processing units are sold only as 1, 2, 4, 6, 8, 10, 12 and 16 Pick a supported size. The provider validates nothing here; this module does.
Capture archives never appear in blob Namespace identity lacks storage access Grant it Storage Blob Data Contributor; set storage_authentication_type.
Producers/consumers get Unauthorized SAS off (local_authentication_enabled = false) and no RBAC grant Assign Azure Event Hubs Data Sender/Receiver to their identities.
consumer_groups apply fails on Basic Basic supports only $Default Remove consumer_groups or raise the SKU.
identity_ids required error identity.type includes UserAssigned but no IDs given Provide identity_ids.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."