IaC Azure

Terraform Module: Azure IoT Hub — fleet-grade device ingestion in one block

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:

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 configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/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

TerraformAzureIoT HubModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading