IaC Azure

Terraform Module: Azure Service Bus — Premium namespaces with private endpoints, queues, and topics

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Service Bus: Premium namespaces, customer-managed keys, private endpoints, queues, topics, and least-privilege SAS rules — wired for production. 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 "service_bus" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-service-bus?ref=v1.0.0"

  namespace_name      = "..."  # Globally unique namespace name (6-50 chars, starts with…
  resource_group_name = "..."  # Resource group for the namespace and private endpoint.
  location            = "..."  # Azure region (e.g. `centralindia`).
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Service Bus is a fully managed enterprise message broker that decouples producers from consumers through queues (point-to-point, competing consumers) and topics with subscriptions (publish/subscribe with per-subscriber filters). It is the backbone for asynchronous workflows: order processing, command/event fan-out, transactional outbox draining, and load-leveling spiky workloads so downstream services aren’t overwhelmed.

The trouble is that a correct Service Bus footprint is never just one resource. A production namespace needs a deliberate SKU choice, network lockdown (public access disabled, private endpoint, optional customer-managed keys on Premium), dead-letter and TTL semantics tuned per entity, duplicate detection where idempotency matters, and scoped SAS authorization rules so each application gets only Send or Listen — never the namespace RootManageSharedAccessKey. Hand-clicking that across dev/test/prod drifts immediately.

This module wraps azurerm_servicebus_namespace together with the entities and access controls you almost always deploy alongside it — queues, topics, subscriptions, namespace-level authorization rules, and an optional private endpoint — behind a small, validated variable surface. You declare the messaging topology once; the module renders a consistent, network-hardened namespace in every environment.

When to use it

If you only need a single throwaway queue for a demo, the raw resource is fine. The module earns its keep the moment you have more than one entity, more than one environment, or any compliance requirement on the network/encryption posture.

Module structure

terraform-module-azure-service-bus/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # namespace, queues, topics, subscriptions, auth rules, private endpoint
├── variables.tf     # validated, var-driven inputs
└── outputs.tf       # ids, names, and (sensitive) connection strings

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

locals {
  # Customer-managed keys and private endpoints are Premium-only features.
  is_premium  = var.sku == "Premium"
  use_cmk     = local.is_premium && var.customer_managed_key != null
  use_pe      = var.private_endpoint != null

  default_tags = merge(
    {
      module      = "terraform-module-azure-service-bus"
      managed_by  = "terraform"
      environment = var.environment
    },
    var.tags
  )
}

resource "azurerm_servicebus_namespace" "this" {
  name                = var.namespace_name
  resource_group_name = var.resource_group_name
  location            = var.location
  sku                 = var.sku

  # capacity (messaging units) only applies to Premium; must be 0 otherwise.
  capacity                      = local.is_premium ? var.premium_messaging_units : 0
  premium_messaging_partitions  = local.is_premium ? var.premium_messaging_partitions : 0
  local_auth_enabled            = var.local_auth_enabled
  public_network_access_enabled = var.public_network_access_enabled
  minimum_tls_version           = var.minimum_tls_version

  dynamic "identity" {
    for_each = var.identity_type == null ? [] : [1]
    content {
      type         = var.identity_type
      identity_ids = var.identity_ids
    }
  }

  dynamic "customer_managed_key" {
    for_each = local.use_cmk ? [var.customer_managed_key] : []
    content {
      key_vault_key_id                  = customer_managed_key.value.key_vault_key_id
      identity_id                       = customer_managed_key.value.identity_id
      infrastructure_encryption_enabled = customer_managed_key.value.infrastructure_encryption_enabled
    }
  }

  tags = local.default_tags
}

# --- Namespace-level SAS authorization rules (least privilege per app) ---
resource "azurerm_servicebus_namespace_authorization_rule" "this" {
  for_each = var.authorization_rules

  name         = each.key
  namespace_id = azurerm_servicebus_namespace.this.id

  listen = each.value.listen
  send   = each.value.send
  manage = each.value.manage
}

# --- Queues ---
resource "azurerm_servicebus_queue" "this" {
  for_each = var.queues

  name         = each.key
  namespace_id = azurerm_servicebus_namespace.this.id

  max_size_in_megabytes                   = each.value.max_size_in_megabytes
  max_delivery_count                      = each.value.max_delivery_count
  lock_duration                           = each.value.lock_duration
  default_message_ttl                     = each.value.default_message_ttl
  requires_duplicate_detection            = each.value.requires_duplicate_detection
  duplicate_detection_history_time_window = each.value.duplicate_detection_history_time_window
  requires_session                        = each.value.requires_session
  dead_lettering_on_message_expiration    = each.value.dead_lettering_on_message_expiration
  # Partitioning is a create-time property; only honoured on Premium namespaces.
  partitioning_enabled                    = local.is_premium ? each.value.partitioning_enabled : false
}

# --- Topics ---
resource "azurerm_servicebus_topic" "this" {
  for_each = var.topics

  name         = each.key
  namespace_id = azurerm_servicebus_namespace.this.id

  max_size_in_megabytes                   = each.value.max_size_in_megabytes
  default_message_ttl                     = each.value.default_message_ttl
  requires_duplicate_detection            = each.value.requires_duplicate_detection
  duplicate_detection_history_time_window = each.value.duplicate_detection_history_time_window
  support_ordering                        = each.value.support_ordering
  partitioning_enabled                    = local.is_premium ? each.value.partitioning_enabled : false
}

# --- Subscriptions (flattened from topics) ---
locals {
  subscriptions = merge([
    for topic_name, topic in var.topics : {
      for sub_name, sub in topic.subscriptions :
      "${topic_name}/${sub_name}" => {
        topic_name = topic_name
        sub_name   = sub_name
        config     = sub
      }
    }
  ]...)
}

resource "azurerm_servicebus_subscription" "this" {
  for_each = local.subscriptions

  name     = each.value.sub_name
  topic_id = azurerm_servicebus_topic.this[each.value.topic_name].id

  max_delivery_count                   = each.value.config.max_delivery_count
  lock_duration                        = each.value.config.lock_duration
  default_message_ttl                  = each.value.config.default_message_ttl
  requires_session                     = each.value.config.requires_session
  dead_lettering_on_message_expiration = each.value.config.dead_lettering_on_message_expiration
  dead_lettering_on_filter_evaluation_error = each.value.config.dead_lettering_on_filter_evaluation_error
}

# --- Optional private endpoint (Premium only) ---
resource "azurerm_private_endpoint" "this" {
  count = local.use_pe ? 1 : 0

  name                = coalesce(var.private_endpoint.name, "${var.namespace_name}-pe")
  resource_group_name = var.resource_group_name
  location            = var.location
  subnet_id           = var.private_endpoint.subnet_id

  private_service_connection {
    name                           = "${var.namespace_name}-psc"
    private_connection_resource_id = azurerm_servicebus_namespace.this.id
    subresource_names              = ["namespace"]
    is_manual_connection           = false
  }

  dynamic "private_dns_zone_group" {
    for_each = length(var.private_endpoint.private_dns_zone_ids) > 0 ? [1] : []
    content {
      name                 = "servicebus-dns-zone-group"
      private_dns_zone_ids = var.private_endpoint.private_dns_zone_ids
    }
  }

  tags = local.default_tags
}

variables.tf

variable "namespace_name" {
  type        = string
  description = "Globally unique Service Bus namespace name (6-50 chars, letters/numbers/hyphens, must start with a letter and end with a letter or number)."

  validation {
    condition     = can(regex("^[A-Za-z][A-Za-z0-9-]{4,48}[A-Za-z0-9]$", var.namespace_name))
    error_message = "namespace_name must be 6-50 chars, start with a letter, end with a letter/number, and contain only letters, numbers, and hyphens."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group that will contain the namespace and private endpoint."
}

variable "location" {
  type        = string
  description = "Azure region for the namespace (e.g. centralindia, eastus)."
}

variable "environment" {
  type        = string
  description = "Environment label applied as a tag (e.g. dev, test, prod)."
  default     = "dev"
}

variable "sku" {
  type        = string
  description = "Namespace SKU. Premium is required for VNet/private endpoints, CMK, and messaging-unit scaling."
  default     = "Standard"

  validation {
    condition     = contains(["Basic", "Standard", "Premium"], var.sku)
    error_message = "sku must be one of: Basic, Standard, Premium."
  }
}

variable "premium_messaging_units" {
  type        = number
  description = "Messaging units (capacity) for Premium namespaces. Ignored unless sku = Premium."
  default     = 1

  validation {
    condition     = contains([1, 2, 4, 8, 16], var.premium_messaging_units)
    error_message = "premium_messaging_units must be one of: 1, 2, 4, 8, 16."
  }
}

variable "premium_messaging_partitions" {
  type        = number
  description = "Messaging partitions for a Premium namespace (partitioned namespaces). Ignored unless sku = Premium."
  default     = 1

  validation {
    condition     = contains([1, 2, 4], var.premium_messaging_partitions)
    error_message = "premium_messaging_partitions must be one of: 1, 2, 4."
  }
}

variable "local_auth_enabled" {
  type        = bool
  description = "Whether SAS (shared access key) authentication is allowed. Set false to enforce Azure AD / RBAC only."
  default     = true
}

variable "public_network_access_enabled" {
  type        = bool
  description = "Whether the namespace is reachable over the public internet. Set false and pair with a private endpoint for production."
  default     = false
}

variable "minimum_tls_version" {
  type        = string
  description = "Minimum TLS version accepted by the namespace."
  default     = "1.2"

  validation {
    condition     = contains(["1.0", "1.1", "1.2"], var.minimum_tls_version)
    error_message = "minimum_tls_version must be one of: 1.0, 1.1, 1.2."
  }
}

variable "identity_type" {
  type        = string
  description = "Managed identity type for the namespace (SystemAssigned, UserAssigned, or 'SystemAssigned, UserAssigned'). Null disables identity."
  default     = null

  validation {
    condition = var.identity_type == null || contains(
      ["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"],
      coalesce(var.identity_type, "SystemAssigned")
    )
    error_message = "identity_type must be null, SystemAssigned, UserAssigned, or 'SystemAssigned, UserAssigned'."
  }
}

variable "identity_ids" {
  type        = list(string)
  description = "User-assigned managed identity resource IDs. Required when identity_type includes UserAssigned (e.g. for CMK)."
  default     = []
}

variable "customer_managed_key" {
  type = object({
    key_vault_key_id                  = string
    identity_id                       = string
    infrastructure_encryption_enabled = optional(bool, false)
  })
  description = "Customer-managed key (CMK) config. Premium-only; requires a user-assigned identity with access to the Key Vault key."
  default     = null
}

variable "authorization_rules" {
  type = map(object({
    listen = optional(bool, false)
    send   = optional(bool, false)
    manage = optional(bool, false)
  }))
  description = "Namespace-level SAS authorization rules, keyed by rule name. Grant least privilege (e.g. send-only producer, listen-only consumer)."
  default     = {}

  validation {
    # 'manage' implies both listen and send, mirroring the Azure API constraint.
    condition = alltrue([
      for r in values(var.authorization_rules) :
      (r.manage == false) || (r.listen && r.send)
    ])
    error_message = "Any authorization rule with manage = true must also set listen = true and send = true."
  }
}

variable "queues" {
  type = map(object({
    max_size_in_megabytes                   = optional(number, 1024)
    max_delivery_count                      = optional(number, 10)
    lock_duration                           = optional(string, "PT1M")
    default_message_ttl                     = optional(string, "P14D")
    requires_duplicate_detection            = optional(bool, false)
    duplicate_detection_history_time_window = optional(string, "PT10M")
    requires_session                        = optional(bool, false)
    dead_lettering_on_message_expiration    = optional(bool, true)
    partitioning_enabled                    = optional(bool, false)
  }))
  description = "Queues to create, keyed by queue name. Durations use ISO-8601 (e.g. PT1M = 1 minute, P14D = 14 days)."
  default     = {}
}

variable "topics" {
  type = map(object({
    max_size_in_megabytes                   = optional(number, 1024)
    default_message_ttl                     = optional(string, "P14D")
    requires_duplicate_detection            = optional(bool, false)
    duplicate_detection_history_time_window = optional(string, "PT10M")
    support_ordering                        = optional(bool, true)
    partitioning_enabled                    = optional(bool, false)
    subscriptions = optional(map(object({
      max_delivery_count                        = optional(number, 10)
      lock_duration                             = optional(string, "PT1M")
      default_message_ttl                       = optional(string, "P14D")
      requires_session                          = optional(bool, false)
      dead_lettering_on_message_expiration      = optional(bool, true)
      dead_lettering_on_filter_evaluation_error = optional(bool, true)
    })), {})
  }))
  description = "Topics (and their subscriptions) to create, keyed by topic name."
  default     = {}
}

variable "private_endpoint" {
  type = object({
    name                 = optional(string)
    subnet_id            = string
    private_dns_zone_ids = optional(list(string), [])
  })
  description = "Optional private endpoint for the namespace data plane (Premium only). Provide subnet_id and the privatelink.servicebus.windows.net DNS zone id."
  default     = null
}

variable "tags" {
  type        = map(string)
  description = "Additional tags merged onto every resource created by the module."
  default     = {}
}

outputs.tf

output "namespace_id" {
  description = "Resource ID of the Service Bus namespace."
  value       = azurerm_servicebus_namespace.this.id
}

output "namespace_name" {
  description = "Name of the Service Bus namespace."
  value       = azurerm_servicebus_namespace.this.name
}

output "namespace_hostname" {
  description = "Fully qualified endpoint of the namespace (used by SDK clients with Azure AD auth)."
  value       = "${azurerm_servicebus_namespace.this.name}.servicebus.windows.net"
}

output "default_primary_connection_string" {
  description = "Primary connection string of the namespace RootManageSharedAccessKey. Avoid in apps; prefer scoped rules or Azure AD."
  value       = azurerm_servicebus_namespace.this.default_primary_connection_string
  sensitive   = true
}

output "identity_principal_id" {
  description = "Principal ID of the namespace's system-assigned identity (null if none)."
  value       = try(azurerm_servicebus_namespace.this.identity[0].principal_id, null)
}

output "queue_ids" {
  description = "Map of queue name => queue resource ID."
  value       = { for k, q in azurerm_servicebus_queue.this : k => q.id }
}

output "topic_ids" {
  description = "Map of topic name => topic resource ID."
  value       = { for k, t in azurerm_servicebus_topic.this : k => t.id }
}

output "authorization_rule_connection_strings" {
  description = "Map of authorization rule name => primary connection string. Push these into Key Vault, not app config."
  value = {
    for k, r in azurerm_servicebus_namespace_authorization_rule.this :
    k => r.primary_connection_string
  }
  sensitive = true
}

output "authorization_rule_primary_keys" {
  description = "Map of authorization rule name => primary key."
  value = {
    for k, r in azurerm_servicebus_namespace_authorization_rule.this :
    k => r.primary_key
  }
  sensitive = true
}

How to use it

A Premium namespace, locked to a private endpoint, with one queue, one pub/sub topic, and least-privilege SAS rules for a producer and a consumer:

module "service_bus" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-service-bus?ref=v1.0.0"

  namespace_name      = "kv-orders-prod-sbns"
  resource_group_name = azurerm_resource_group.messaging.name
  location            = "centralindia"
  environment         = "prod"

  sku                           = "Premium"
  premium_messaging_units       = 2
  public_network_access_enabled = false
  local_auth_enabled            = true
  minimum_tls_version           = "1.2"

  authorization_rules = {
    "order-producer" = { send = true }
    "order-consumer" = { listen = true }
  }

  queues = {
    "orders-inbound" = {
      max_delivery_count                      = 5
      lock_duration                           = "PT5M"
      requires_duplicate_detection            = true
      duplicate_detection_history_time_window = "PT30M"
      dead_lettering_on_message_expiration    = true
    }
  }

  topics = {
    "order-events" = {
      support_ordering = true
      subscriptions = {
        "billing"   = { max_delivery_count = 10 }
        "analytics" = { default_message_ttl = "P7D" }
      }
    }
  }

  private_endpoint = {
    subnet_id            = azurerm_subnet.privatelink.id
    private_dns_zone_ids = [azurerm_private_dns_zone.servicebus.id]
  }

  tags = {
    cost_center = "platform"
    owner       = "messaging-team"
  }
}

# Downstream: stash the producer's scoped connection string in Key Vault
# so the order API can pull it at runtime without RootManageSharedAccessKey.
resource "azurerm_key_vault_secret" "order_producer_conn" {
  name         = "servicebus-order-producer"
  value        = module.service_bus.authorization_rule_connection_strings["order-producer"]
  key_vault_id = azurerm_key_vault.platform.id
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root 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/service_bus/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-service-bus?ref=v1.0.0"
}

inputs = {
  namespace_name = "..."
  resource_group_name = "..."
  location = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/service_bus && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
namespace_name string Yes Globally unique namespace name (6-50 chars, starts with a letter).
resource_group_name string Yes Resource group for the namespace and private endpoint.
location string Yes Azure region (e.g. centralindia).
environment string "dev" No Environment label applied as a tag.
sku string "Standard" No Basic, Standard, or Premium (Premium needed for VNet/CMK).
premium_messaging_units number 1 No Messaging units for Premium (1/2/4/8/16).
premium_messaging_partitions number 1 No Messaging partitions for a Premium namespace (1/2/4).
local_auth_enabled bool true No Allow SAS auth; set false to force Azure AD/RBAC only.
public_network_access_enabled bool false No Allow public data-plane access. Keep false in prod.
minimum_tls_version string "1.2" No Minimum TLS version (1.0/1.1/1.2).
identity_type string null No SystemAssigned, UserAssigned, or both.
identity_ids list(string) [] No User-assigned identity IDs (required for CMK).
customer_managed_key object null No CMK config (Premium only): key id + identity + infra encryption.
authorization_rules map(object) {} No Namespace SAS rules keyed by name (listen/send/manage).
queues map(object) {} No Queues keyed by name with TTL, lock, dedup, DLQ, session settings.
topics map(object) {} No Topics (and nested subscriptions) keyed by name.
private_endpoint object null No Private endpoint config: subnet_id + private_dns_zone_ids.
tags map(string) {} No Extra tags merged onto all resources.

Outputs

Name Description
namespace_id Resource ID of the Service Bus namespace.
namespace_name Name of the namespace.
namespace_hostname FQDN (<name>.servicebus.windows.net) for SDK clients using Azure AD.
default_primary_connection_string RootManageSharedAccessKey primary connection string (sensitive; avoid in apps).
identity_principal_id Principal ID of the system-assigned identity, or null.
queue_ids Map of queue name to resource ID.
topic_ids Map of topic name to resource ID.
authorization_rule_connection_strings Map of rule name to primary connection string (sensitive).
authorization_rule_primary_keys Map of rule name to primary key (sensitive).

Enterprise scenario

A retail platform runs order intake across three regions and must guarantee that a customer’s order events are processed in arrival order and never double-charged. The team deploys one Premium namespace per region with premium_messaging_units = 2, an orders-inbound queue using requires_session = true (per-customer ordering) plus duplicate detection over a 30-minute window, and an order-events topic fanning out to billing and analytics subscriptions. The namespace has public_network_access_enabled = false and sits behind a private endpoint wired to the privatelink.servicebus.windows.net zone, so the data plane is reachable only from the spoke VNet — and the order API pulls a send-only SAS string from Key Vault, never the root key.

Best practices

TerraformAzureService BusModuleIaC
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