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
- You need asynchronous, durable messaging between services and want queues/topics defined as code rather than portal clicks.
- You require ordered, exactly-once-style processing using sessions and duplicate detection (Premium/Standard features).
- You’re enforcing a private-only data plane: no public network access, traffic over a private endpoint inside a spoke VNet.
- You want least-privilege SAS rules per app (one
Send-only key for producers, oneListen-only key for consumers) emitted as connection-string outputs for Key Vault. - You operate multiple environments and need identical queue/topic semantics (lock duration, max delivery count, TTL) reproduced everywhere.
- You’re standardizing on Premium for VNet integration, predictable throughput (messaging units), and customer-managed encryption keys.
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 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/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
- Never ship the root key. Keep
RootManageSharedAccessKeyfor break-glass only; create per-appauthorization_rules(send-only producers,listen-only consumers) or move to Azure AD RBAC withlocal_auth_enabled = falseand theAzure Service Bus Data Sender/Data Receiverroles. - Lock the network on Premium. Set
public_network_access_enabled = falseand attach aprivate_endpointwith the correct private DNS zone; Basic/Standard cannot do VNet integration, so size for Premium when isolation is a requirement. - Tune entity semantics deliberately. Match
lock_durationto your longest handler, capmax_delivery_countso poison messages dead-letter instead of looping, and enablerequires_duplicate_detectiononly where idempotency truly matters (it costs throughput and storage). - Right-size before scaling units. Messaging units (1/2/4/8/16) are the Premium cost lever — start at 1-2 and scale on namespace CPU/throttle metrics rather than provisioning headroom you never use.
- Encrypt sensitive workloads with CMK. On Premium, pass a
customer_managed_keybacked by a user-assigned identity withKey Vault Crypto Service Encryption User, and enableinfrastructure_encryption_enabledfor double encryption on regulated data. - Name and tag predictably. Use a region/workload/env convention (e.g.
kv-orders-prod-sbns) so namespaces are greppable across subscriptions, and rely on the module’senvironment/tagsmerge for consistent cost attribution.