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
- You send mobile push notifications (iOS/Android) and want the hub, its platform credentials, and access keys defined as code rather than portal clicks.
- You’re migrating to FCM v1 (the legacy FCM/GCM API was retired in 2024) and need the service-account JSON wired consistently across environments.
- You manage multiple hubs in one namespace — e.g. one per app or per tenant — and want identical tagging and access posture for each.
- You require least-privilege access keys: a
Listen-only SAS for the app to register devices, a separateSend-enabled SAS for the backend, never the namespace root key. - You operate dev/test/prod and need the same hub names, credentials wiring, and rules reproduced exactly — with the SAS connection strings emitted as outputs for Key Vault.
- You want APNs configured in token (.p8) mode so you stop rotating expiring
.p12certificates by hand.
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 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/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
- Never give clients send capability. Hand the mobile app a
Listen-only access rule and keep a separatesend-enabled rule for the backend; never shipDefaultFullSharedAccessSignature, which can both register and send. Pull keys from Key Vault, not embedded config. - Prefer APNs token (.p8) over certificates. Token auth (
key_id+team_id+.p8) does not expire like.p12certificates and works across all your bundle IDs — setapplication_mode = "Sandbox"only for debug builds and"Production"for the App Store/TestFlight pipeline. - Move to FCM v1, not legacy. The legacy FCM/GCM HTTP API was shut down in 2024; use
fcm_v1_credentialwith the service-accountclient_emailandprivate_key, and treat that JSON as a secret (Key Vault /sensitiveTerraform vars), never committed. - Right-size the tier.
Freecaps at ~1M pushes/month with no SLA; chooseStandardfor scheduled push, rich tag expressions, multi-tenancy, and the device-count headroom production needs — and remember the namespace, not the hub, is the billing unit. - One namespace, many hubs, consistent tags. Group per-app or per-tenant hubs under a single regional namespace and drive naming/
tagsthrough the module so hubs stay greppable across subscriptions and cost attribution is automatic. - Treat credentials as rotatable. Store the
.p8and service-account JSON outside state-bearing repos, reference them viafile()/jsondecode()from a secrets mount, and rotate the FCM key and any leaked SAS keys promptly — Notification Hubs exposes both primary and secondary keys for zero-downtime rotation.