Quick take — A reusable hashicorp/azurerm 4.x Terraform module for Azure IoT Hub: SKU-driven scaling, custom event-hub endpoints, message routing, and consumer groups wired up for production telemetry. 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 "iot_hub" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-iot-hub?ref=v1.0.0"
resource_group_name = "..." # Resource group to deploy the IoT Hub into.
location = "..." # Azure region for the IoT Hub.
environment = "..." # Environment short code (`dev`/`test`/`stage`/`prod`).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure IoT Hub is the managed front door for bidirectional communication between millions of devices and the Azure cloud. It terminates per-device identities (MQTT, AMQP, or HTTPS), authenticates them with symmetric keys, X.509 certificates, or DPS-enrolled credentials, applies quota and throttling per SKU unit, and then fans telemetry out to downstream sinks — Event Hubs, Service Bus, Storage, or the built-in events endpoint — through a message-routing engine.
Wiring IoT Hub correctly by hand is deceptively fiddly. You have to pick the right SKU and capacity (the F1 free tier allows exactly one per subscription and caps at 8,000 messages/day; S1/S2/S3 differ by message quota and device-to-cloud throughput), declare each endpoint before any route can reference it by name, attach consumer_group resources to the right eventhub_endpoint_name, and remember that file_upload needs a pre-existing storage container and a SAS-capable connection string. Get the ordering wrong and terraform apply fails late, after the hub itself is already half-provisioned.
This module wraps azurerm_iothub plus the three sub-resources almost every production hub needs — a custom Event Hubs routing endpoint, a route that targets it, and named consumer groups — behind a small, validated variable surface. You get a hub that is sized, routed, and ready for a downstream stream processor in a single module block, with the dependency ordering handled for you.
When to use it
Reach for this module when:
- You run more than one IoT Hub — per environment (dev/test/prod), per region for data-residency, or per business unit — and want them identical apart from SKU and capacity.
- You need telemetry routed to a custom Event Hub or Service Bus for a Stream Analytics / Databricks / Functions pipeline, rather than relying only on the built-in
eventsendpoint. - You want consumer groups provisioned as code so each downstream reader (hot-path analytics, cold-path archival, a monitoring tap) gets its own checkpoint cursor instead of fighting over
$Default. - You are standardising IoT landing zones and want SKU/capacity, routing, and tagging enforced by
variable validationinstead of review-time comments.
Skip it if you only ever need a single throwaway F1 hub for a demo — a raw resource is simpler there.
Module structure
terraform-module-azure-iot-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 {
# IoT Hub names must be globally unique, 3-50 chars, alphanumeric + hyphens.
iothub_name = coalesce(var.name, "iot-${var.workload}-${var.environment}")
# Only build the routing block when a custom Event Hub endpoint is supplied.
enable_custom_routing = var.eventhub_endpoint != null
}
resource "azurerm_iothub" "this" {
name = local.iothub_name
resource_group_name = var.resource_group_name
location = var.location
public_network_access_enabled = var.public_network_access_enabled
local_authentication_enabled = var.local_authentication_enabled
min_tls_version = var.min_tls_version
sku {
name = var.sku_name
capacity = var.sku_capacity
}
# Tune the built-in device-to-cloud partition count (immutable after create).
event_hub_partition_count = var.event_hub_partition_count
cloud_to_device {
max_delivery_count = var.cloud_to_device_max_delivery_count
default_ttl = var.cloud_to_device_default_ttl
feedback {
time_to_live = "PT1H10M"
max_delivery_count = 10
}
}
# Optional custom Event Hubs routing endpoint.
dynamic "endpoint" {
for_each = local.enable_custom_routing ? [var.eventhub_endpoint] : []
content {
type = "AzureIotHub.EventHub"
name = endpoint.value.name
connection_string = endpoint.value.connection_string
authentication_type = "keyBased"
resource_group_name = var.resource_group_name
}
}
# Route that ships telemetry to the custom endpoint above.
dynamic "route" {
for_each = local.enable_custom_routing ? [var.eventhub_endpoint] : []
content {
name = "route-${route.value.name}"
source = "DeviceMessages"
condition = var.route_condition
endpoint_names = [route.value.name]
enabled = true
}
}
# Optional file-upload settings for device blob uploads.
dynamic "file_upload" {
for_each = var.file_upload != null ? [var.file_upload] : []
content {
connection_string = file_upload.value.connection_string
container_name = file_upload.value.container_name
sas_ttl = "PT1H"
notifications = true
lock_duration = "PT1M"
default_ttl = "PT1H"
max_delivery_count = 10
}
}
tags = var.tags
}
# One consumer group per downstream reader, attached to the built-in
# events endpoint so each pipeline gets an independent checkpoint cursor.
resource "azurerm_iothub_consumer_group" "this" {
for_each = toset(var.consumer_groups)
name = each.value
iothub_name = azurerm_iothub.this.name
eventhub_endpoint_name = "events"
resource_group_name = var.resource_group_name
}
variables.tf
variable "resource_group_name" {
type = string
description = "Name of the resource group to deploy the IoT Hub into."
}
variable "location" {
type = string
description = "Azure region for the IoT Hub (e.g. centralindia, westeurope)."
}
variable "name" {
type = string
default = null
description = "Explicit globally-unique IoT Hub name. If null, derived from workload + environment."
validation {
condition = var.name == null || can(regex("^[A-Za-z0-9-]{3,50}$", var.name))
error_message = "IoT Hub name must be 3-50 characters: letters, digits, and hyphens only."
}
}
variable "workload" {
type = string
default = "telemetry"
description = "Short workload identifier used to build the hub name when 'name' is null."
}
variable "environment" {
type = string
description = "Environment short code used in naming and tagging (e.g. dev, prod)."
validation {
condition = contains(["dev", "test", "stage", "prod"], var.environment)
error_message = "environment must be one of: dev, test, stage, prod."
}
}
variable "sku_name" {
type = string
default = "S1"
description = "IoT Hub SKU tier. F1 is free (one per subscription); B/S tiers are paid."
validation {
condition = contains(["F1", "B1", "B2", "B3", "S1", "S2", "S3"], var.sku_name)
error_message = "sku_name must be one of: F1, B1, B2, B3, S1, S2, S3."
}
}
variable "sku_capacity" {
type = number
default = 1
description = "Number of provisioned IoT Hub units. Must be 1 for the F1 free tier."
validation {
condition = var.sku_capacity >= 1 && var.sku_capacity <= 200
error_message = "sku_capacity must be between 1 and 200."
}
}
variable "event_hub_partition_count" {
type = number
default = 4
description = "Partitions for the built-in device-to-cloud Event Hub. Immutable after create; 2-128."
validation {
condition = var.event_hub_partition_count >= 2 && var.event_hub_partition_count <= 128
error_message = "event_hub_partition_count must be between 2 and 128."
}
}
variable "min_tls_version" {
type = string
default = "1.2"
description = "Minimum TLS version devices may negotiate. Use 1.2 for production."
validation {
condition = contains(["1.0", "1.2"], var.min_tls_version)
error_message = "min_tls_version must be either 1.0 or 1.2."
}
}
variable "public_network_access_enabled" {
type = bool
default = true
description = "Whether the hub is reachable over the public internet. Set false when using Private Link."
}
variable "local_authentication_enabled" {
type = bool
default = true
description = "Allow shared-access-key (SAS) auth. Set false to force Entra ID / per-device identities only."
}
variable "cloud_to_device_max_delivery_count" {
type = number
default = 10
description = "Max delivery attempts for cloud-to-device messages (1-100)."
validation {
condition = var.cloud_to_device_max_delivery_count >= 1 && var.cloud_to_device_max_delivery_count <= 100
error_message = "cloud_to_device_max_delivery_count must be between 1 and 100."
}
}
variable "cloud_to_device_default_ttl" {
type = string
default = "PT1H"
description = "Default time-to-live for cloud-to-device messages as an ISO 8601 duration."
}
variable "eventhub_endpoint" {
type = object({
name = string
connection_string = string
})
default = null
description = "Optional custom Event Hubs routing endpoint. When set, a DeviceMessages route is created for it."
}
variable "route_condition" {
type = string
default = "true"
description = "Routing query expression applied to DeviceMessages for the custom endpoint."
}
variable "file_upload" {
type = object({
connection_string = string
container_name = string
})
default = null
description = "Optional storage connection string + container enabling device file (blob) uploads."
}
variable "consumer_groups" {
type = list(string)
default = []
description = "Consumer group names to create on the built-in 'events' endpoint, one per downstream reader."
}
variable "tags" {
type = map(string)
default = {}
description = "Tags applied to the IoT Hub resource."
}
outputs.tf
output "id" {
description = "Resource ID of the IoT Hub."
value = azurerm_iothub.this.id
}
output "name" {
description = "Name of the IoT Hub."
value = azurerm_iothub.this.name
}
output "hostname" {
description = "Fully-qualified hostname devices connect to (e.g. iot-telemetry-prod.azure-devices.net)."
value = azurerm_iothub.this.hostname
}
output "event_hub_events_endpoint" {
description = "Event Hub-compatible endpoint of the built-in events route, for downstream stream readers."
value = azurerm_iothub.this.event_hub_events_endpoint
}
output "event_hub_events_path" {
description = "Event Hub-compatible path (name) of the built-in events route."
value = azurerm_iothub.this.event_hub_events_path
}
output "shared_access_policy" {
description = "Built-in shared access policies (iothubowner, service, registryReadWrite, etc.) with their keys."
value = azurerm_iothub.this.shared_access_policy
sensitive = true
}
output "consumer_group_names" {
description = "Consumer group names created on the events endpoint."
value = [for cg in azurerm_iothub_consumer_group.this : cg.name]
}
How to use it
module "iot_hub" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-iot-hub?ref=v1.0.0"
resource_group_name = azurerm_resource_group.iot.name
location = azurerm_resource_group.iot.location
workload = "fleet"
environment = "prod"
sku_name = "S2"
sku_capacity = 4
event_hub_partition_count = 8
# Lock the hub down for production.
min_tls_version = "1.2"
local_authentication_enabled = false
# Route raw device telemetry to a dedicated Event Hub for Stream Analytics.
eventhub_endpoint = {
name = "telemetry-eh"
connection_string = azurerm_eventhub_authorization_rule.ingest.primary_connection_string
}
route_condition = "$body.type = 'telemetry'"
# One checkpoint cursor per downstream pipeline.
consumer_groups = ["hotpath-asa", "coldpath-archive", "monitoring-tap"]
tags = {
workload = "fleet-telemetry"
environment = "prod"
owner = "iot-platform"
}
}
# Downstream: a Stream Analytics input that reads the hub's built-in events
# endpoint using one of the consumer groups the module created.
resource "azurerm_stream_analytics_stream_input_iothub" "telemetry" {
name = "iothub-telemetry"
stream_analytics_job_name = azurerm_stream_analytics_job.fleet.name
resource_group_name = azurerm_resource_group.iot.name
iothub_namespace = module.iot_hub.name
endpoint = "messages/events"
shared_access_policy_name = "iothubowner"
shared_access_policy_key = "" # supplied from a Key Vault data source in real code
consumer_group_name = "hotpath-asa"
serialization {
type = "Json"
encoding = "UTF8"
}
}
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/iot_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-iot-hub?ref=v1.0.0"
}
inputs = {
resource_group_name = "..."
location = "..."
environment = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/iot_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 |
|---|---|---|---|---|
resource_group_name |
string |
— | Yes | Resource group to deploy the IoT Hub into. |
location |
string |
— | Yes | Azure region for the IoT Hub. |
name |
string |
null |
No | Explicit globally-unique hub name; derived from workload+environment when null. |
workload |
string |
"telemetry" |
No | Short workload identifier used to build the name when name is null. |
environment |
string |
— | Yes | Environment short code (dev/test/stage/prod). |
sku_name |
string |
"S1" |
No | SKU tier: F1, B1-B3, or S1-S3. |
sku_capacity |
number |
1 |
No | Number of provisioned units (1-200; must be 1 for F1). |
event_hub_partition_count |
number |
4 |
No | Built-in device-to-cloud partitions (2-128, immutable after create). |
min_tls_version |
string |
"1.2" |
No | Minimum negotiable TLS version (1.0 or 1.2). |
public_network_access_enabled |
bool |
true |
No | Whether the hub is reachable over the public internet. |
local_authentication_enabled |
bool |
true |
No | Allow SAS-key auth; set false to force Entra ID only. |
cloud_to_device_max_delivery_count |
number |
10 |
No | Max C2D delivery attempts (1-100). |
cloud_to_device_default_ttl |
string |
"PT1H" |
No | Default C2D message TTL (ISO 8601 duration). |
eventhub_endpoint |
object({name, connection_string}) |
null |
No | Custom Event Hubs routing endpoint; creates a DeviceMessages route when set. |
route_condition |
string |
"true" |
No | Routing query applied to DeviceMessages for the custom endpoint. |
file_upload |
object({connection_string, container_name}) |
null |
No | Storage connection + container enabling device blob uploads. |
consumer_groups |
list(string) |
[] |
No | Consumer group names created on the built-in events endpoint. |
tags |
map(string) |
{} |
No | Tags applied to the IoT Hub. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the IoT Hub. |
name |
Name of the IoT Hub. |
hostname |
Fully-qualified hostname devices connect to (*.azure-devices.net). |
event_hub_events_endpoint |
Event Hub-compatible endpoint of the built-in events route. |
event_hub_events_path |
Event Hub-compatible path (name) of the built-in events route. |
shared_access_policy |
Built-in shared access policies and their keys (sensitive). |
consumer_group_names |
Consumer group names created on the events endpoint. |
Enterprise scenario
A logistics company runs 40,000 refrigerated-trailer telemetry units reporting temperature, GPS, and door state every 30 seconds. They stamp out one S2 hub per region (India Central, West Europe, East US) from this module with local_authentication_enabled = false so every trailer authenticates with a DPS-issued X.509 certificate. Each hub routes $body.type = 'telemetry' messages to a regional Event Hub feeding Stream Analytics for cold-chain breach alerting, while three module-managed consumer groups keep the real-time alerting job, the long-term Data Lake archive, and the SRE monitoring tap on independent checkpoint cursors — so a backlog in archival never stalls the breach alerts.
Best practices
- Force device identity over shared keys. Set
local_authentication_enabled = falseand provision per-device X.509 certificates through DPS; a single leaked SAS key otherwise grants fleet-wide access. Pinmin_tls_version = "1.2"so no device negotiates down. - Never overscale the SKU blindly. IoT Hub bills per unit per day regardless of traffic, and
event_hub_partition_countis immutable after create — size partitions for peak device-to-cloud throughput up front, but startsku_capacitylow and scale units as message-quota metrics demand. - Give every reader its own consumer group. Each downstream pipeline (hot-path analytics, cold-path archive, monitoring) needs a dedicated consumer group; sharing
$Defaultcauses readers to steal each other’s offsets and silently drop telemetry. - Route deliberately and add a fallback. Define explicit routes with a
route_conditionso only relevant messages hit paid Event Hubs, but keep the built-in fallback route enabled so unmatched messages are not silently discarded. - Lock the data plane with Private Link. For regulated fleets set
public_network_access_enabled = falseand reach the hub over a private endpoint, keeping device-to-cloud traffic off the public internet. - Tag and name for multi-region fleets. Drive names from
workload+environmentand tag every hub with owner and environment so 40,000-device, multi-region estates stay auditable and cost-attributable.