IaC Azure

Terraform Module: Azure Notification Hub — namespace, hubs, APNs/FCM credentials, and least-privilege access rules

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Notification Hubs: a namespace, one or more hubs, APNs (token & certificate) and FCM v1 push credentials, and scoped Listen/Send access rules — wired for production push. 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 "notification_hub" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-notification-hub?ref=v1.0.0"

  namespace_name      = "..."  # Globally unique namespace name (6-50 chars, starts with…
  resource_group_name = "..."  # Resource group for the namespace and hubs.
  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 Notification Hubs is a managed push notification engine. Instead of your backend talking to each platform-specific gateway directly — Apple Push Notification service (APNs), Firebase Cloud Messaging (FCM v1), Windows Notification Service (WNS) — you register devices against a hub, tag them (user:123, topic:sports, lang:en), and send one templated message that the hub fans out to millions of devices across platforms. It handles PNS feedback, expired registrations, and the per-platform throttling you’d otherwise hand-roll.

The catch is that a correct Notification Hubs footprint is never a single resource. A namespace is the billable, regional container (Free / Basic / Standard tier); inside it you create one or more hubs, each carrying the platform credentials (APNs token or certificate, the FCM v1 service-account JSON) and a set of access rules. And those access rules matter: the default DefaultFullSharedAccessSignature can both register devices and send notifications, so handing it to a mobile app leaks your send capability to every phone. Production needs a Listen-only key for clients and a Send/Listen key for the backend — defined identically across dev/test/prod.

This module wraps azurerm_notification_hub_namespace together with azurerm_notification_hub and azurerm_notification_hub_authorization_rule behind a small, validated variable surface. You declare the namespace tier, your hubs, their APNs/FCM credentials, and the access rules once; the module renders a consistent, least-privilege push backend in every environment.

When to use it

If you’re firing a handful of test pushes from the portal, the raw resource is fine. The module earns its keep the moment you have more than one hub, more than one environment, or any requirement to keep send capability off client devices.

Module structure

terraform-module-azure-notification-hub/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # namespace, hubs, APNs/FCM credentials, access rules
├── variables.tf     # validated, var-driven inputs
└── outputs.tf       # ids, names, and (sensitive) access-rule connection strings

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  default_tags = merge(
    {
      module      = "terraform-module-azure-notification-hub"
      managed_by  = "terraform"
      environment = var.environment
    },
    var.tags
  )

  # Flatten { hub_name => { rule_name => rule } } into a single keyed map
  # "hub_name/rule_name" so each access rule is its own for_each instance.
  access_rules = merge([
    for hub_name, hub in var.hubs : {
      for rule_name, rule in hub.access_rules :
      "${hub_name}/${rule_name}" => {
        hub_name  = hub_name
        rule_name = rule_name
        listen    = rule.listen
        send      = rule.send
        manage    = rule.manage
      }
    }
  ]...)
}

resource "azurerm_notification_hub_namespace" "this" {
  name                = var.namespace_name
  resource_group_name = var.resource_group_name
  location            = var.location

  # Notification Hubs namespaces are always namespace_type = "NotificationHub".
  namespace_type = "NotificationHub"
  sku_name       = var.sku_name
  enabled        = var.enabled

  tags = local.default_tags
}

resource "azurerm_notification_hub" "this" {
  for_each = var.hubs

  name                = each.key
  namespace_name      = azurerm_notification_hub_namespace.this.name
  resource_group_name = var.resource_group_name
  location            = var.location

  # --- Apple Push Notification service (APNs) ---
  dynamic "apns_credential" {
    for_each = each.value.apns_credential == null ? [] : [each.value.apns_credential]
    content {
      application_mode = apns_credential.value.application_mode # "Sandbox" or "Production"
      bundle_id        = apns_credential.value.bundle_id        # e.g. com.kloudvin.app
      key_id           = apns_credential.value.key_id           # APNs auth key (.p8) Key ID
      team_id          = apns_credential.value.team_id          # Apple Developer Team ID
      token            = apns_credential.value.token            # contents of the .p8 auth key
    }
  }

  # --- Firebase Cloud Messaging v1 (replaces legacy gcm/FCM legacy) ---
  dynamic "fcm_v1_credential" {
    for_each = each.value.fcm_v1_credential == null ? [] : [each.value.fcm_v1_credential]
    content {
      project_id                  = fcm_v1_credential.value.project_id
      application_name            = fcm_v1_credential.value.application_name # FCM client email
      service_account_private_key = fcm_v1_credential.value.service_account_private_key
    }
  }

  tags = local.default_tags
}

resource "azurerm_notification_hub_authorization_rule" "this" {
  for_each = local.access_rules

  name                  = each.value.rule_name
  notification_hub_name = azurerm_notification_hub.this[each.value.hub_name].name
  namespace_name        = azurerm_notification_hub_namespace.this.name
  resource_group_name   = var.resource_group_name

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

variables.tf

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

  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 its hubs."
}

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

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

variable "sku_name" {
  type        = string
  description = "Namespace pricing tier. Standard adds scheduled push, multi-tenancy, and higher quotas; Free is capped at 1M pushes/month."
  default     = "Standard"

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

variable "enabled" {
  type        = bool
  description = "Whether the namespace is enabled and able to process notifications."
  default     = true
}

variable "hubs" {
  type = map(object({
    apns_credential = optional(object({
      application_mode = string # "Sandbox" (dev builds) or "Production"
      bundle_id        = string
      key_id           = string
      team_id          = string
      token            = string # contents of the APNs .p8 auth key
    }))
    fcm_v1_credential = optional(object({
      project_id                  = string
      application_name            = string # FCM service-account client email
      service_account_private_key = string # private_key from the service-account JSON
    }))
    access_rules = optional(map(object({
      listen = optional(bool, true)
      send   = optional(bool, false)
      manage = optional(bool, false)
    })), {})
  }))
  description = "Notification hubs to create, keyed by hub name. Each hub can carry APNs and/or FCM v1 credentials and a map of scoped access rules."
  default     = {}

  validation {
    # APNs application_mode is constrained by the Azure API.
    condition = alltrue([
      for h in values(var.hubs) :
      h.apns_credential == null ||
      contains(["Sandbox", "Production"], h.apns_credential.application_mode)
    ])
    error_message = "apns_credential.application_mode must be either 'Sandbox' or 'Production'."
  }

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

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 Notification Hubs namespace."
  value       = azurerm_notification_hub_namespace.this.id
}

output "namespace_name" {
  description = "Name of the Notification Hubs namespace."
  value       = azurerm_notification_hub_namespace.this.name
}

output "namespace_servicebus_endpoint" {
  description = "Service Bus endpoint of the namespace (base URI used by management/SDK clients)."
  value       = azurerm_notification_hub_namespace.this.servicebus_endpoint
}

output "hub_ids" {
  description = "Map of hub name => hub resource ID."
  value       = { for k, h in azurerm_notification_hub.this : k => h.id }
}

output "hub_names" {
  description = "List of hub names created in the namespace."
  value       = [for h in azurerm_notification_hub.this : h.name]
}

output "access_rule_primary_connection_strings" {
  description = "Map of 'hub/rule' => primary connection string. Push these into Key Vault, not app config."
  value = {
    for k, r in azurerm_notification_hub_authorization_rule.this :
    k => r.primary_connection_string
  }
  sensitive = true
}

output "access_rule_primary_access_keys" {
  description = "Map of 'hub/rule' => primary access key."
  value = {
    for k, r in azurerm_notification_hub_authorization_rule.this :
    k => r.primary_access_key
  }
  sensitive = true
}

How to use it

A Standard namespace with one hub, APNs in token (.p8) mode, FCM v1 credentials, and two scoped access rules — a Listen-only key for the mobile app and a Send-enabled key for the backend:

module "notification_hub" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-notification-hub?ref=v1.0.0"

  namespace_name      = "kv-mobile-prod-nhns"
  resource_group_name = azurerm_resource_group.mobile.name
  location            = "centralindia"
  environment         = "prod"
  sku_name            = "Standard"

  hubs = {
    "kv-consumer-app" = {
      apns_credential = {
        application_mode = "Production"
        bundle_id        = "com.kloudvin.consumer"
        key_id           = "ABC1234DEF"
        team_id          = "TEAM567890"
        token            = file("${path.root}/secrets/AuthKey_ABC1234DEF.p8")
      }

      fcm_v1_credential = {
        project_id                  = "kloudvin-consumer"
        application_name            = jsondecode(file("${path.root}/secrets/fcm-sa.json")).client_email
        service_account_private_key = jsondecode(file("${path.root}/secrets/fcm-sa.json")).private_key
      }

      access_rules = {
        # Mobile clients register devices but cannot send.
        "app-listen"     = { listen = true }
        # Backend sends notifications (send implies listen).
        "backend-send"   = { listen = true, send = true }
      }
    }
  }

  tags = {
    cost_center = "mobile"
    owner       = "apps-team"
  }
}

# Downstream: stash the backend's send-capable connection string in Key Vault
# so the notification service pulls it at runtime — never the namespace root key.
resource "azurerm_key_vault_secret" "nh_backend_conn" {
  name         = "notificationhub-backend-send"
  value        = module.notification_hub.access_rule_primary_connection_strings["kv-consumer-app/backend-send"]
  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/notification_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-notification-hub?ref=v1.0.0"
}

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

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

cd live/prod/notification_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
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 hubs.
location string Yes Azure region (e.g. centralindia).
environment string "dev" No Environment label applied as a tag.
sku_name string "Standard" No Pricing tier: Free, Basic, or Standard.
enabled bool true No Whether the namespace can process notifications.
hubs map(object) {} No Hubs keyed by name, each with optional apns_credential, fcm_v1_credential, and access_rules.
tags map(string) {} No Extra tags merged onto all resources.

The nested hubs object accepts:

Field Type Default Description
apns_credential object null APNs token-auth fields: application_mode (Sandbox/Production), bundle_id, key_id, team_id, token (.p8 contents).
fcm_v1_credential object null FCM v1 fields: project_id, application_name (service-account email), service_account_private_key.
access_rules map(object) {} SAS rules keyed by name with listen/send/manage booleans.

Outputs

Name Description
namespace_id Resource ID of the Notification Hubs namespace.
namespace_name Name of the namespace.
namespace_servicebus_endpoint Service Bus endpoint (base URI) of the namespace.
hub_ids Map of hub name to resource ID.
hub_names List of hub names in the namespace.
access_rule_primary_connection_strings Map of hub/rule to primary connection string (sensitive).
access_rule_primary_access_keys Map of hub/rule to primary access key (sensitive).

Enterprise scenario

A consumer fintech ships separate iOS and Android apps and must alert users to transactions the instant they post. The team deploys one Standard namespace per region with a kv-consumer-app hub carrying APNs in token (.p8) mode — so no certificate expiry pages the on-call — and FCM v1 credentials sourced from the service-account JSON. Devices register with tags like user:<id>, and the payments service sends per-user transaction alerts using a backend-send SAS key pulled from Key Vault, while the mobile apps hold only an app-listen key, so a decompiled binary cannot be used to spam the entire install base.

Best practices

TerraformAzureNotification 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