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.
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.
If this module saves you time, please consider supporting it:
- ⭐ Star the repository to help others find it.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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.
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;
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. |
| 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, andlocationon the namespace are immutable — changing any of them forces replacement of the namespace and every child hub and consumer group.⚠️ dedicated_cluster_idis force-new — moving a namespace into or out of a dedicated cluster replaces it.- 🔴
azurerm_eventhub.partition_countis 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_countcannot be changed on shared namespaces unless the namespace sku isPremium". 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_countandmessage_retentionare 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 atterraform validaterather than at apply. - 🔴
capacityhas 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_enabledandcapture_descriptionrequire Standard or above; so doesnetwork_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_enabledandnetwork_rulesets.public_network_access_enabledmust 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 totrueinside the provider while this module's namespace default isfalse, 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_rulesetsisOptionalandComputed. Removing the block from a configuration does not remove the firewall — the provider adopts whatever Azure reports and shows no diff. Setdefault_action = "Allow"explicitly to open the namespace back up. - 🔴 Changing
skuto or from Premium forces replacement of the namespace, and with it every hub and consumer group. The rule lives in the provider'sCustomizeDiff, not its schema, so nothing in the binary schema shows it. Basic to Standard and back is in place. ⚠️ On the pinned 4.x lineminimum_tls_versionaccepts1.0,1.1and1.2; the 5.0 schema — which is what the registry documentation describes — accepts only1.2. The module accepts all three and reports a below-floor choice, because refusing would leave an existing namespace unmanageable and would blockterraform destroy.- ℹ️
maximum_throughput_unitsis only honored whenauto_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 — sonslookupand 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_retentionand a per-hubretention_descriptionare not merely alternatives — the provider marks themExactlyOneOf, so exactly one of the two is REQUIRED on every hub. A hub defined with only a name and a partition count cannotterraform validate; there is no "accept the service default" option.retention_descriptionalso carries a secondExactlyOneOfbetweenretention_time_in_hoursandtombstone_retention_time_in_hours. Neither constraint appears in the binary schema. ⚠️ capture_description.destination.storage_authentication_typedefaults 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". Thecapture_uses_shared_key_event_hubsoutput names every hub in that state.⚠️ azurerm_eventhubandazurerm_eventhub_consumer_groupsupport no tags at all. The namespace tag is the only one there is; it is not inherited.
Least-privilege, at the smallest scope that works:
- Management plane (provisioning):
Contributoron the target resource group, or a custom role grantingMicrosoft.EventHub/namespaces/*,Microsoft.EventHub/namespaces/eventhubs/*, andMicrosoft.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) needsStorage Blob Data Contributoron the target storage account. Without one of those, capture falls back to the provider's default ofStorageSASand authenticates with a shared key.
🔴 Plan access is credential access on this resource. The provider declares six computed,
sensitiveattributes onazurerm_eventhub_namespace— the primary and secondary keys, their connection strings, and two alias connection strings — and itsReadfunction fetches them from theRootManageSharedAccessKeyrule on every refresh. Terraform reads computed attributes whenever it refreshes, so anyone who can runterraform planagainst 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 = falsedoes 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 outputsensitiveredacts plan output, it does not encrypt state.
- An existing resource group in a supported US Azure region (e.g.
eastus,eastus2,westus2,centralus). - The
Microsoft.EventHubresource 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.
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
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.
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.
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, andminimum_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 stablefor_eachidentifiers. 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_keymust match a key inevent_hubs. The consumer group resolves itseventhub_namefrom that hub, so the dependency ordering is implicit — nodepends_onneeded.
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 instorage_authentication_id) needsStorage Blob Data Contributoron 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 Contributoron 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_unitsis only honored whenauto_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_actiondefaults to"Deny"— only the listed IP ranges and subnets are admitted. Setpublic_network_access_enabled = trueso 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_idsis required whenevertypeincludesUserAssigned. 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_countcan 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
$Defaultconsumer group — do not addconsumer_groupson 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"
}
}ℹ️
tagsapply to the namespace.timeoutsvalues 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/locationdrive both data resources, the storage account'sidbecomes the capture destination, and the namespace'sidis the scope for the Data Sender grant. Grant the namespace identityStorage Blob Data Contributoron the capture account for archives to succeed.
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.
| 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.
- One keystone, keyed children.
azurerm_eventhub_namespace.thisis the single owned resource; hubs and consumer groups arefor_eachover 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'snameinsidemain.tf. That reference makes the create/destroy ordering implicit — Terraform builds the namespace, then the hub, then the consumer group, with nodepends_on. - Force-new realities. Changing
name,resource_group_nameorlocationreplaces the whole namespace and its children, and so does movingskuto or from Premium — that last one from aCustomizeDiffthe schema does not show. A hub'spartition_countis 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
ExactlyOneOfbetweenmessage_retentionandretention_descriptionis invisible in the binary schema and means a hub with neither cannotterraform validateat all. There is no service default to fall back on. - The firewall does not come off when you delete the block.
network_rulesetsisOptionalandComputed, so an absent block reads as adopt whatever exists. Removing it produces an empty plan and leaves the namespace firewalled exactly as before. Setdefault_action = "Allow"to open it back up. - Two public-access switches that must agree. The namespace's own
public_network_access_enabledand the nested one insidenetwork_rulesetsare compared by the provider, which aborts the apply when they differ. Their defaults disagree —falsehere,trueinside the provider's nested block — somain.tfrenders the nested value from the namespace value whenever the caller leaves it unset, andvariables.tfrefuses an explicit contradiction offline. - Capture authenticates with a shared key by default.
storage_authentication_typedefaults to"StorageSAS", a value the API does not define. Set"SystemAssigned"or"UserAssigned"and grant that identityStorage Blob Data Contributoron the destination account;capture_uses_shared_key_event_hubslists 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_descriptionneed 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-hubcapture_description/retention_description, andtimeoutsare alldynamicblocks gated on a non-null value, withtry(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'sprovider "azurerm" { features {} }is required for initialization. Its absence in a root module is the usual cause of an init failure.
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 |
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 planandterraform applyfrom CI against real credentials; the module authoring process never applies.
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-typedobject()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.
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"
| 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. |
- Provider resources:
azurerm_eventhub_namespace,azurerm_eventhub,azurerm_eventhub_consumer_group. - Microsoft Learn: Azure Event Hubs documentation, Event Hubs Capture, Authorize with Microsoft Entra ID.
- Sibling modules:
terraform-azurerm-resource-group,terraform-azurerm-storage-account,terraform-azurerm-role-assignments,terraform-azurerm-private-endpoint,terraform-azurerm-virtual-network,terraform-azurerm-user-assigned-identity. - This module's
SCOPE.md— the cross-module contract.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

