One application's push endpoint inside a Notification Hubs namespace (
azurerm_notification_hub). Targetshashicorp/azurerm ~> 4.0.
- π² A hub holds one application's push credentials. The namespace above it holds one purpose; the hub maps to one app.
- π΄ Android push cannot be configured through this resource.
gcm_credentialcarries the FCM legacy key, which Google retired on 20 June 2024 β and this provider exposes no FCM v1 arguments at all. - π΄ Only three of Azure's eight push platforms are here. WNS, ADM and Xiaomi are current and absent; MPNS and Baidu are retired platforms, so no loss.
- π΄ Removing a credential block replaces the hub, per an Azure SDK bug the provider documents β destroying every device registration. Editing one is an in-place update.
β οΈ browser_credentialis force-new on any change, not just removal. Three blocks, three lifecycles.β οΈ apns_credential.application_mode = "Sandbox"fails silently for production device tokens.- π Every credential is in Terraform state in plaintext.
sensitiveredacts plan output, not state. - π No credential value is emitted β presence only.
- β
tagsare supported here, unlike on the authorization rules beneath.
π‘ Why it matters: this resource looks like a tidy list of three optional credential blocks. In fact one of them targets a dead API, the three have three different replacement behaviours, and the whole set lands in state in plaintext. The module's job is to make each of those legible before an apply rather than after an outage.
If this module saves you time, please consider supporting its continued development:
- β Star the repository on GitHub.
- π€ Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
flowchart TB
ns["terraform-azurerm-notification-hub-namespace. Pass its NAME, resource_group_name and location - a hub needs three separate name arguments and there is no single parent ID. The namespace must be of type NotificationHub, not the legacy Messaging, and that is force-new there."]
hub["terraform-azurerm-notification-hub"]
rule["terraform-azurerm-notification-hub-authorization-rule takes this hub's NAME plus the namespace and resource group. It is where the SHARED ACCESS KEYS are minted - this module holds push credentials, that one holds client credentials."]
apns["APPLE, via apns_credential. Token-based. application_mode picks the gateway, and Sandbox fails SILENTLY for production device tokens."]
web["BROWSERS, via browser_credential. A VAPID pair - the public half is not a secret and the provider agrees. But the whole block is FORCE-NEW, so rotating the pair rebuilds the hub."]
fcm["ANDROID: gcm_credential carries the FCM LEGACY api_key, and Google RETIRED that API on 20 June 2024. FCM v1 needs a private key, a client email and a project ID, and this provider exposes NONE of them - so Android push must be configured out of band, outside Terraform's view."]
gap["AND WNS, ADM AND XIAOMI ARE ABSENT TOO. ARM has eight credential kinds; this provider has three. MPNS and Baidu are retired so no loss, but those three are current."]
ns -->|"name, resource_group_name, location"| hub
hub -->|"name"| rule
hub -->|"configures"| apns
hub -->|"configures"| web
hub -->|"cannot usefully configure"| fcm
hub -->|"cannot configure at all"| gap
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class hub mine;
class fcm,gap keystone;
class ns,rule,apns,web sib;
flowchart TB
three["THREE CREDENTIAL BLOCKS, THREE DIFFERENT LIFECYCLES - which no reader would guess from three similar-looking optional blocks. Editing any credential is an IN-PLACE update. REMOVING apns_credential or gcm_credential FORCES RECREATION, per an Azure SDK for Go bug the provider documents. And browser_credential is force-new on ANY change."]
contagion["THE SECRETS ARE WRAPPED WITH sensitive() AT POINT OF USE, not by marking the variables sensitive - and the reason is NOT the for_each reason used elsewhere in this library, since these are single blocks. It is CONTAGION: a sensitive object would make application_mode and vapid_public_key sensitive too, and Terraform refuses to emit a sensitive bool, so the derived flags below would break."]
pubkey["vapid_public_key is deliberately NOT wrapped. The provider does not mark it sensitive, because a VAPID public key exists to be handed to browsers. This module follows the provider's own split rather than redacting both halves of a pair."]
probable["The validations catch DOCUMENTED PROBABLE MISTAKES: a token pasted WITH its PEM markers when the provider says to send the contents between them; a filename instead of a key; a PEM block where a base64url VAPID key belongs; identical key halves. Two further checks are HEURISTICS and say so in their own messages."]
sandbox["apns_targets_the_sandbox_gateway: derived, and it exists because the failure is SILENT. Production device tokens are not valid on Apple's development gateway, so notifications are simply not delivered - an outage that reads like a client bug."]
retired["configures_the_retired_fcm_legacy_credential plus fcm_v1_cannot_be_configured_through_this_provider: the retirement is REPORTED, not enforced. The provider accepts the block and Azure still stores it, so rejecting it would refuse legal input and break a caller mid-migration."]
nostate["credentials_are_in_terraform_state_in_plaintext: always true, and it matters more than the sensitive markings. sensitive redacts plan output; it does not encrypt state. Treat anyone with state access as holding the push credentials for every registered device."]
none["configures_no_push_platform: derived rather than enforced, because the provider documents no credential as required and a hub created ahead of its credentials is a legitimate intermediate state."]
this["azurerm_notification_hub.this"]
three -->|"lifecycle"| this
contagion -->|"rendering"| this
pubkey -->|"exception"| contagion
probable -->|"validated"| this
this -->|"emits"| sandbox
this -->|"emits"| retired
this -->|"emits"| nostate
this -->|"emits"| none
classDef mine fill:#0078D4,stroke:#004578,color:#fff;
classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
class this mine;
class contagion mine;
class retired,nostate keystone;
class three,pubkey,probable,sandbox,none sib;
Resource inventory
| Resource | Count | Notes |
|---|---|---|
azurerm_notification_hub.this |
1 | The keystone. |
apns_credential |
0..1 | iOS/macOS. Editable in place; removal replaces the hub. |
browser_credential |
0..1 | Web Push. Force-new on any change. |
gcm_credential |
0..1 | π΄ FCM legacy β retired API. |
timeouts |
0..1 | All four; raise delete. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Azure resource type | Microsoft.NotificationHubs/namespaces/notificationHubs |
| Provider block | None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication. |
Schema notes that bite β confirmed against the live provider schema and Microsoft's documentation:
- π΄
gcm_credentialis FCM legacy, retired 20 June 2024 (example 5). - π΄ No FCM v1 arguments exist in this provider (example 5).
- π΄ Removing
apns_credentialorgcm_credentialforces recreation (example 7). β οΈ browser_credentialis force-new on any change (examples 4, 7).β οΈ application_mode = "Sandbox"is Apple's dev gateway (example 3).β οΈ tokenis the.p8contents, without the PEM markers (example 3).β οΈ vapid_public_keyis NOT provider-marked sensitive; the private key is (example 4).β οΈ Force-new:name,namespace_name,resource_group_name,location(example 8).- β
tagsare supported (example 8). lifecycleis not valid inside amoduleblock (example 11).
| Operation | Role | Scope |
|---|---|---|
| Create, update or delete the hub | Contributor | the namespace |
| Read it | Reader | the hub |
| π Read the push credentials this module sets | whatever grants state access | the Terraform backend, not Azure |
| π΄ Send notifications | a shared access key on an authorization rule | the hub |
π The third row is not an Azure permission, and that is the point. The APNS signing key, the VAPID private key and the FCM key are all in Terraform state in plaintext (example 9). So the effective access control on those secrets is whoever can read the state backend β which is frequently a wider group than whoever holds Contributor on the namespace.
π΄ The fourth row is a different resource entirely. The credentials this module holds let Azure talk to Apple and Google; the credentials that let your application talk to Azure come from an authorization rule.
- π΄ A namespace of type
NotificationHub, not the legacyMessagingβ and that is force-new on the namespace (example 1). - π΄ An FCM v1 configuration applied out of band, if Android is a target platform (example 5).
- π An encrypted, access-controlled state backend before any credential is set (example 9).
β οΈ The APNS key contents, not the.p8file path (example 3).β οΈ A VAPID key pair, if Web Push is a target (example 4).
terraform-azurerm-notification-hub/
βββ providers.tf # required_version + the pinned azurerm provider. No provider block.
βββ variables.tf # name, namespace_name, resource_group_name, location,
# # apns_credential, browser_credential, gcm_credential, tags, timeouts
βββ main.tf # the keystone `this`; sensitive() at point of use
βββ outputs.tf # id first, then identity, presence flags, and five constants
βββ README.md # this document
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
provider "azurerm" {
features {}
}
module "notification_hub" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"
name = "nh-exampleapp"
# β οΈ Three NAMES, not an ID -- source all three from the namespace module.
namespace_name = module.notification_hub_namespace.name
resource_group_name = module.notification_hub_namespace.resource_group_name
location = module.notification_hub_namespace.location
# π A real secret, and it lands in state in plaintext (example 9).
apns_credential = {
application_mode = "Production" # β οΈ not "Sandbox" (example 3)
bundle_id = "com.contoso.exampleapp"
key_id = var.apns_key_id
team_id = var.apple_team_id
token = var.apns_signing_key # the .p8 CONTENTS, no PEM markers
}
tags = { environment = "prod", workload = "push" }
}π΄ Android is deliberately absent from that call.
gcm_credentialwould configure a retired API; FCM v1 has to be set outside Terraform (example 5).
βΉοΈ The caller configures the provider, its authentication, and the mandatory
features {}block. This module declares none of them.
Consumes
| Input | Type | Source module |
|---|---|---|
name |
string |
caller β force-new |
namespace_name |
string |
terraform-azurerm-notification-hub-namespace output name |
resource_group_name |
string |
that module's resource_group_name β locates the namespace |
location |
string |
that module's location β should match it |
apns_credential |
object(...) |
π caller β a real secret |
browser_credential |
object(...) |
π caller β force-new on any change |
gcm_credential |
object(...) |
π΄ caller β a retired API |
tags |
map(string) |
caller β β supported |
timeouts |
object(...) |
caller β raise delete |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The hub's Resource ID. | role assignments, diagnostics |
name |
π΄ What an authorization rule consumes. | terraform-azurerm-notification-hub-authorization-rule |
namespace_name / resource_group_name |
The other two names a rule needs. | that module |
location |
The hub's region. | review |
has_apns_credential / has_browser_credential / has_gcm_credential |
π Presence only. | posture review |
configures_no_push_platform |
false. |
posture review |
apns_targets_the_sandbox_gateway |
false in production. |
delivery review |
configures_the_retired_fcm_legacy_credential |
π΄ Derived. Assert false. |
platform review |
fcm_v1_cannot_be_configured_through_this_provider |
π΄ Always true. |
design review |
only_three_push_platforms_are_configurable_here |
π΄ Always true. |
design review |
removing_a_credential_block_replaces_the_hub |
π΄ Always true. |
change review |
credentials_are_in_terraform_state_in_plaintext |
π Always true. |
security review |
1 Β· The smallest real hub
module "notification_hub" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"
name = "nh-exampleapp"
namespace_name = module.notification_hub_namespace.name
resource_group_name = module.notification_hub_namespace.resource_group_name
location = module.notification_hub_namespace.location
}βΉοΈ Four required arguments and no credentials β which is legal, and produces a hub that can register devices and deliver nothing:
output "check" { value = module.notification_hub.configures_no_push_platform } # β οΈ expect falseβ That flag is derived rather than enforced, because the provider documents no credential as required and a hub created ahead of its credentials is a legitimate intermediate state. Rejecting it would refuse legal input.
β οΈ Note this resource needs THREE separate name arguments and there is no single parent ID. All three must agree, and this module cannot check that they describe the same object β so source all three from the namespace module rather than hard-coding any of them.
π΄ The namespace must be of type
NotificationHub. The legacyMessagingtype is not what a hub expects to live in, and the namespace's type is force-new β so that is a decision already made before this module runs, and one it cannot see.
2 Β· A hub, a namespace, and what each one owns
NAMESPACE one purpose: dev / test / prod
owns the SKU (the billed unit), the region, zone redundancy,
disaster recovery, and tags
|
+-- HUB one application
owns the PUSH CREDENTIALS -- Azure's credentials for talking to
Apple and Google. And tags.
|
+-- AUTHORIZATION RULE one consumer
owns the SHARED ACCESS KEYS -- your application's credentials
for talking to Azure. No tags.
β Two different kinds of credential, one layer apart, and they are easy to conflate. This module holds the keys Azure uses outbound to the push services. The authorization rule holds the keys your code uses inbound to Azure.
βΉοΈ Tags reach the hub but not the rule, so a hub is the lowest level in this family a tag policy can govern (example 8).
β οΈ Deleting anything deletes everything below it, including device registrations β which client applications created at runtime, so they are not in Terraform state and nothing recreates them (example 11).
π‘ Microsoft's guidance is one hub per application, with related hubs grouped in a namespace per environment. A hub holds one credential per platform, so two apps sharing a hub would share Apple credentials.
3 Β· β οΈ APNS: the gateway that fails silently, and the key that is not a file
apns_credential = {
application_mode = "Production" # β οΈ or "Sandbox" -- see below
bundle_id = "com.contoso.exampleapp"
key_id = "ABC123DEFG"
team_id = "HIJ456KLMN"
token = var.apns_signing_key
}
β οΈ Sandboxis Apple's development gateway, and choosing it wrongly fails silently. A production build's device tokens are not valid there, so notifications are simply not delivered β no rejection, no useful error. That reads as a client bug:
output "delivery_check" {
value = module.notification_hub.apns_targets_the_sandbox_gateway # β οΈ expect false in production
}π΄
tokenis the CONTENTS of the.p8key, without the PEM markers. The provider is explicit that it is the text between-----BEGIN PRIVATE KEY-----and-----END PRIVATE KEY-----. Pasting the whole file is the predictable mistake, so the module rejects it:
apns_credential.token must be the CONTENTS of the .p8 key, without the PEM markers -- the provider
documents it as the text BETWEEN the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.
Strip those lines and any surrounding blank lines.
β οΈ And a filename is rejected too, sincetoken = "AuthKey_ABC123DEFG.p8"is the other way to get this wrong.
βΉοΈ
bundle_idis checked for a dot, and that check is a HEURISTIC β the message says so. A bundle ID is conventionally reverse-DNS, and a value without a dot is far more likely to be a team ID or a key ID pasted into the wrong field. The provider documents no format, so the module infers rather than asserts.
π The token is a real secret and it lands in state in plaintext (example 9).
4 Β· β οΈ Web Push: a public key that is not a secret, and a block that is force-new
browser_credential = {
subject = "mailto:push-admin@contoso.com"
vapid_public_key = var.vapid_public_key # published to browsers
vapid_private_key = var.vapid_private_key # a real secret
}β The provider marks
vapid_private_keysensitive andvapid_public_keynot β and this module follows that split rather than redacting both. A VAPID public key exists to be handed to browsers; redacting it would break plan review while protecting nothing. That is the same reasoning this library applies to public keys generally, and here the provider reached it first.
π΄ But the whole block is force-new on ANY change, unlike the other two credentials. So rotating a VAPID key pair through Terraform destroys the hub and every device registration on it. Treat a rotation as a migration, not an edit (example 7).
β οΈ VAPID keys are base64url, not PEM. Pasting a.pemfile is the predictable mistake and the module rejects it β and it also rejects identical public and private halves, since one of them is then wrong.
βΉοΈ
subjectis a VAPID contact URI, conventionallymailto:or anhttps://URL, so a push service can reach whoever operates the application. The provider documents it only as "the subject name of web push", so the module does not enforce a scheme β it flags a bare email address and says the check is a heuristic:
browser_credential.subject looks like a bare email address. A VAPID subject is conventionally a
contact URI -- "mailto:push-admin@contoso.com" rather than "push-admin@contoso.com". This check is a
HEURISTIC, not a documented provider rule ...
5 Β· π΄ Android: a retired API, and no replacement in this provider
Google stopped supporting FCM legacy HTTP on 20 JUNE 2024.
Microsoft: "As of June 2024, FCM legacy APIs are no longer supported and are retired."
gcm_credential { api_key = "..." } <- the FCM LEGACY credential. One key.
FCM v1 -- the supported protocol -- needs THREE values:
a private key, a client email, and a project ID
This provider version exposes NO fcm_v1_credential block and no equivalent arguments.
π΄ So Android push cannot be configured from Terraform at all:
output "gap" {
value = module.notification_hub.fcm_v1_cannot_be_configured_through_this_provider # always true
}
output "assert_this" {
value = module.notification_hub.configures_the_retired_fcm_legacy_credential # π΄ expect false
}π΄ FCM v1 must be configured out of band β the Azure portal's Google (FCM v1) blade, the REST API, or an Azure SDK β and that configuration then sits outside Terraform's view. Expect no plan to show it and no drift detection on it.
β The block is still exposed here, deliberately. The provider accepts it and Azure still stores it, so refusing it would reject input that is legal today and would break a caller mid-migration. This module reports the retirement rather than enforcing a judgement β the same report-don't-refuse position this library takes elsewhere.
β οΈ Note how this differs from a retired resource. Where a whole resource or family is the retired experience, this library declines to author it. Here the retirement is scoped to one optional argument of an otherwise current resource β iOS and Web Push both work β so the module ships and names the dead block.
π‘ Re-verify against current provider releases before designing around this. It is a gap that should eventually close.
6 Β· π΄ Five platforms this provider cannot configure
ARM's notification hub carries EIGHT credential kinds:
apns fcm wns adm baidu mpns browser xiaomi
This provider exposes THREE:
apns_credential browser_credential gcm_credential
Missing, and RETIRED platforms -- no loss:
mpns (Windows Phone)
baidu
Missing, and CURRENT:
wns (Windows)
adm (Amazon Fire)
xiaomi
π΄ So a hub serving Windows, Amazon Fire or Xiaomi devices cannot be fully configured from Terraform, and needs the same out-of-band treatment as FCM v1:
output "coverage" {
value = module.notification_hub.only_three_push_platforms_are_configurable_here # always true
}β Distinguishing the two groups is the useful part. MPNS and Baidu being absent is correct β they are retired platforms this library would refuse to author for anyway. WNS, ADM and Xiaomi being absent is a provider gap, and worth raising upstream rather than working around.
β οΈ This is emitted as a constant rather than derived because it is a property of the provider, not of your configuration β there is nothing in the plan that could reveal it.
π‘ Check the platform list before choosing Terraform as the configuration point for a multi-platform hub. Partial configuration across two tools is worse than one tool consistently.
7 Β· π΄ Three blocks, three lifecycles
EDITING a credential -> in-place update β
REMOVING apns_credential -> FORCES RECREATION π΄
REMOVING gcm_credential -> FORCES RECREATION π΄
ANY change to browser_credential -> FORCES RECREATION π΄
And recreating the hub destroys every DEVICE REGISTRATION on it.
π΄ The removal behaviour is an upstream bug, not a design. The provider documents it against an Azure SDK for Go issue β so it may be fixed, and it should be re-checked rather than assumed permanent:
output "change_review" {
value = module.notification_hub.removing_a_credential_block_replaces_the_hub # always true
}
β οΈ This asymmetry is what no reader would guess from three similar-looking optional blocks. Adding Apple credentials is safe; taking them away is an outage. Andbrowser_credentialdoes not even need to be removed β changing it is enough.
π΄ Device registrations are the loss that matters. Client applications create them at runtime, so they are not in Terraform state and nothing recreates them. Every installed app has to re-register.
π‘ If a credential must go away, plan it as a migration: stand up a new hub, move clients, retire the old one. Do not achieve it by deleting a block from HCL.
8 Β· Force-new, and where tags reach
π΄ Force-new -- each replaces the hub, destroying device registrations:
name
namespace_name
resource_group_name
location
β
Updates in place:
tags
the CONTENTS of apns_credential and gcm_credential (but not their removal)
β
tagsare supported here and not on the authorization rules beneath, so a hub is the lowest level in this family an Azure Policy requiring tags can reach. Tag the namespace too β it is the billed unit.
β οΈ locationshould match the namespace's own region. The provider even describes it as the region "in which this Notification Hub Namespace exists" β a hub is not independently placeable, and this module cannot read the namespace to check.
β οΈ Renaming a hub is not a rename. It is a new hub, and the old registrations do not follow.
π‘ Source all three of
namespace_name,resource_group_nameandlocationfrom the namespace module. Three independent literals are three chances to disagree, and the failure is a not-found at apply rather than a validation error.
9 Β· π Every credential is in state, in plaintext
# All three of these are real secrets:
apns_credential.token
browser_credential.vapid_private_key
gcm_credential.api_key
# All three are provider-marked sensitive, and wrapped with sensitive() at point of use.
# ALL THREE ARE STILL IN TERRAFORM STATE IN PLAINTEXT.π
sensitive = trueredacts plan and apply output. It does not encrypt state. So the control that actually protects these values is an encrypted, access-controlled remote backend β never a local state file in a repository:
output "read_this" {
value = module.notification_hub.credentials_are_in_terraform_state_in_plaintext # always true
}π΄ Treat anyone with state access as holding the push credentials for every device your application has registered. That is a wider group than whoever holds Contributor on the namespace, which is why the permissions table has a row that is not an Azure permission at all.
β No credential value is emitted by this module β only presence:
output "platforms" {
value = {
apple = module.notification_hub.has_apns_credential
browser = module.notification_hub.has_browser_credential
android = module.notification_hub.has_gcm_credential # π΄ expect false (example 5)
}
}β Presence-only is deliberate. Re-emitting a secret already in this module's state would copy it into every consuming configuration's state as well, widening the blast radius for no benefit. A consumer needing the key should read it from the same secret store this module read it from.
π‘ Source every credential from a secret store, not from a committed
.tfvars. The variables are not marked sensitive (see the Inputs section for why), so a value in a variable file is plainly visible.
10 Β· Why the credential variables are not marked `sensitive`
# variables.tf -- NOT marked sensitive:
variable "apns_credential" {
type = object({
application_mode = string
# ... token = string
})
}
# main.tf -- sensitive() applied to the secret FIELD, at the point of use:
token = sensitive(apns_credential.value.token)
β οΈ The reason is contagion, and it is not the reason that applies elsewhere in this library. Elsewhere a collection is left unmarked because a sensitive value cannot be afor_eachargument. These are single blocks, so that does not apply β a sensitive variable would render perfectly well.
π΄ The problem is that sensitivity spreads to the whole object. Marking
apns_credentialsensitive would makeapplication_modesensitive too, soapns_targets_the_sandbox_gatewaywould become a sensitive bool β and Terraform refuses to emit one. The same applies tovapid_public_key, which is not a secret at all.
β So the secret fields are wrapped individually, at the point of use β which the provider already marks sensitive in its schema anyway, making this belt-and-braces rather than the only protection.
β οΈ The trade-off is honest and worth stating: a value passed in a.tfvarsfile is not redacted at the variable level. That is why the guidance is to source credentials from a secret store rather than a variable file, and why example 9 exists.
βΉοΈ This is the second distinct reason this library has for not marking a variable sensitive. Both end in the same technique β
sensitive()at point of use β for different causes.
11 Β· Destroy, locks and importing
terraform import 'module.notification_hub.azurerm_notification_hub.this' \
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-push-prod/providers/Microsoft.NotificationHubs/namespaces/nhn-push-prod/notificationHubs/nh-exampleapp"π΄ Destroying the hub destroys every device registration on it. Those were created by client applications at runtime, so they are not in state and nothing recreates them β every installed app has to re-register. Raise the
deletetimeout and mean it.
β οΈ An import will not bring the credentials with it meaningfully. The secret fields are write-only in effect: Azure does not return them, so an imported hub shows a diff proposing to set them, and the values must come from wherever they were originally stored.
β οΈ prevent_destroyis not available, becauselifecycleis not valid inside amoduleblock. ACanNotDeletemanagement lock on the namespace is the available protection β and it prevents deletion, not the force-new replacements of examples 7 and 8.
β Passing
namespace_nameas an attribute orders the destroy β the hub goes before the namespace, and the authorization rules go before the hub.
π‘ On anything user-facing, the lock is worth the inconvenience, precisely because the loss is not recoverable from Terraform state.
12 Β· Hubs per application from one map
module "hubs" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"
for_each = {
consumer = "com.contoso.consumerapp"
field = "com.contoso.fieldapp"
}
name = "nh-${each.key}"
namespace_name = module.notification_hub_namespace.name
resource_group_name = module.notification_hub_namespace.resource_group_name
location = module.notification_hub_namespace.location
apns_credential = {
application_mode = "Production"
bundle_id = each.value # a different Apple app per hub
key_id = var.apns_key_id
team_id = var.apple_team_id
token = var.apns_signing_key
}
tags = { environment = "prod", app = each.key }
}β
for_eachover stable application keys, nevercount, so removing one app never re-indexes the rest β which matters more than usual here, becausenameis force-new and a re-index would destroy hubs and their registrations.
β One hub per application is the point of the map. A hub holds one credential per platform, so two apps in one hub would share a
bundle_idβ which is whybundle_idvaries per entry above while the signing key does not.
βΉοΈ The APNS key and team are shared because they belong to the Apple developer team, not to an individual app. Only the bundle ID distinguishes the apps.
π΄ Every hub in that map inherits the same platform gap (examples 5, 6). The map changes how many apps you serve, not which platforms you can configure.
13 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-push-prod"
location = "eastus2"
tags = { environment = "prod", workload = "push" }
}
# ββ The namespace: the billed unit, and the resilience decisions βββββββββββββ
module "notification_hub_namespace" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub-namespace.git?ref=v1.0.0"
name = "nhn-push-prod"
resource_group_name = module.rg.name
location = module.rg.location
# π΄ Must be NotificationHub, not the legacy Messaging -- and it is force-new there.
namespace_type = "NotificationHub"
sku_name = "Standard"
tags = { environment = "prod", workload = "push" }
}
# ββ The hub: Azure's credentials for talking to the push services ββββββββββββ
module "notification_hub" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"
name = "nh-exampleapp"
# β οΈ Three names, all from the namespace module so they cannot disagree (example 8).
namespace_name = module.notification_hub_namespace.name
resource_group_name = module.notification_hub_namespace.resource_group_name
location = module.notification_hub_namespace.location
# π From a secret store, never a committed tfvars (examples 9, 10).
# β οΈ Production, not Sandbox -- Sandbox fails silently (example 3).
apns_credential = {
application_mode = "Production"
bundle_id = "com.contoso.exampleapp"
key_id = var.apns_key_id
team_id = var.apple_team_id
token = var.apns_signing_key # the .p8 CONTENTS, no PEM markers
}
# π The public half is not a secret and is not redacted; the private half is.
# β οΈ This whole block is force-new, so a VAPID rotation is a migration (example 4).
browser_credential = {
subject = "mailto:push-admin@contoso.com"
vapid_public_key = var.vapid_public_key
vapid_private_key = var.vapid_private_key
}
# π΄ gcm_credential deliberately OMITTED. It configures the FCM legacy API, which
# Google retired on 20 June 2024, and this provider has no FCM v1 arguments --
# so Android push is configured out of band, in the portal's Google (FCM v1)
# blade, and lives outside Terraform's view (example 5).
tags = { environment = "prod", workload = "push", app = "exampleapp" }
timeouts = { delete = "60m" } # registrations do not come back (example 11)
}
output "hub_posture" {
value = {
id = module.notification_hub.id
name = module.notification_hub.name
# π Presence only -- no credential is emitted (example 9):
platforms = {
apple = module.notification_hub.has_apns_credential
browser = module.notification_hub.has_browser_credential
android = module.notification_hub.has_gcm_credential
}
# β
Assertions worth automating:
no_platform = module.notification_hub.configures_no_push_platform # expect false
apple_sandbox = module.notification_hub.apns_targets_the_sandbox_gateway # expect false
retired_android = module.notification_hub.configures_the_retired_fcm_legacy_credential # expect false
# π΄ Runbook facts, not statuses:
android_gap = module.notification_hub.fcm_v1_cannot_be_configured_through_this_provider
platform_gap = module.notification_hub.only_three_push_platforms_are_configurable_here
removal_note = module.notification_hub.removing_a_credential_block_replaces_the_hub
state_warning = module.notification_hub.credentials_are_in_terraform_state_in_plaintext
}
}β What the composition gets right: all three location arguments sourced from the namespace module so they cannot disagree,
Productionchosen explicitly becauseSandboxfails silently, credentials from variables a pipeline populates from a secret store,gcm_credentialomitted with a comment explaining why rather than silently absent, tags on the hub and the namespace, a raiseddeletetimeout, and the three silent-failure assertions exported for CI.
π΄ What still needs a human: an encrypted state backend before any of this is applied (example 9); the FCM v1 configuration in the portal, plus a note somewhere durable that it is not managed by Terraform (example 5); and the authorization rules that give the application its own credentials β a separate module, which is where the shared access keys live (example 2).
β οΈ Note the composition creates no authorization rule. That is deliberate: the hub holds Azure's outbound credentials, and the rule holds the application's inbound ones β two different concerns, one layer apart.
| Input | Type | Default | Notes |
|---|---|---|---|
name |
string |
β | Required. Force-new. |
namespace_name |
string |
β | Required. Force-new. A name. |
resource_group_name |
string |
β | Required. Force-new. Locates the namespace. |
location |
string |
β | Required. Force-new. Should match the namespace. |
apns_credential |
object(...) |
null |
π iOS/macOS. Removal replaces the hub. |
browser_credential |
object(...) |
null |
π Web Push. Force-new on any change. |
gcm_credential |
object(...) |
null |
π΄ FCM legacy β retired API. |
tags |
map(string) |
{} |
β Supported. |
timeouts |
object({ create, read, update, delete }) |
null |
Raise delete. |
π None of the credential variables is marked
sensitive, deliberately β see example 10. The secret fields are wrapped withsensitive()at the point of use inmain.tf, and the provider marks them sensitive in its own schema.
Full schemas
variable "apns_credential" {
type = object({
application_mode = string # Production | Sandbox -- Sandbox fails SILENTLY for prod tokens
bundle_id = string
key_id = string
team_id = string
token = string # the .p8 CONTENTS, WITHOUT the PEM markers
})
default = null
# The documented probable mistake. strcontains(), not a regex -- these are literals.
validation {
condition = var.apns_credential == null ? true : !(
strcontains(var.apns_credential.token, "BEGIN PRIVATE KEY") ||
strcontains(var.apns_credential.token, "END PRIVATE KEY")
)
error_message = "apns_credential.token must be the CONTENTS of the .p8 key, without the PEM markers ..."
}
# A HEURISTIC, and the message says so.
validation {
condition = var.apns_credential == null ? true : strcontains(var.apns_credential.bundle_id, ".")
error_message = "apns_credential.bundle_id does not contain a dot, which suggests a team ID or a key ID was passed instead ... This check is a HEURISTIC, not a documented rule ..."
}
}
variable "gcm_credential" {
type = object({ api_key = string })
default = null
# Reported, not refused: the provider accepts it and Azure still stores it, so rejecting
# it would refuse legal input and break a caller mid-migration. See example 5.
}| Output | Description | Sensitive |
|---|---|---|
id |
The hub's Resource ID. | no |
name / namespace_name / resource_group_name / location |
Identity. All force-new. | no |
has_apns_credential / has_browser_credential / has_gcm_credential |
π Presence only. | no |
configures_no_push_platform |
no | |
apns_targets_the_sandbox_gateway |
no | |
configures_the_retired_fcm_legacy_credential |
π΄ Derived. | no |
fcm_v1_cannot_be_configured_through_this_provider |
π΄ Always true. |
no |
only_three_push_platforms_are_configurable_here |
π΄ Always true. |
no |
removing_a_credential_block_replaces_the_hub |
π΄ Always true. |
no |
credentials_are_in_terraform_state_in_plaintext |
π Always true. |
no |
π No output is sensitive, because no credential is emitted. Presence flags only β see example 9 for why re-emitting a secret already in state is the wrong trade.
-
Three optional credential blocks that look interchangeable have three different lifecycles and one dead target. That is the whole shape of this resource, and none of it is visible from the argument list β which is why five of the module's outputs are constants rather than values.
-
The FCM finding is a retirement scoped to an ARGUMENT, not to a resource or a family. This library has refused whole families before on retirement grounds. Here
azurerm_notification_hubis current and actively useful β iOS and Web Push both work β while one optional block targets an API Google removed on 20 June 2024, and the provider offers no replacement. The right response is to author the module and report the dead block, not to refuse. -
And the block is rendered rather than rejected, because the provider accepts it and Azure still stores it. Rejecting it would refuse input that is legal today and would break a caller part-way through a migration β the report-don't-refuse position this library takes whenever enforcement would reject legal input.
-
fcm_v1_cannot_be_configured_through_this_providermatters more than the retirement flag. The retirement is a fact about Google; this is a fact about what a caller can accomplish here. Android push has to be configured out of band, and that configuration then has no plan, no drift detection and no record in Terraform. -
The platform-coverage constant distinguishes absent-and-retired from absent-and-current. MPNS and Baidu missing is correct. WNS, ADM and Xiaomi missing is a provider gap worth raising upstream. Collapsing the two would lose the actionable half.
-
removing_a_credential_block_replaces_the_hubdescribes an upstream bug, and says so. The provider documents it against an Azure SDK for Go issue, so it may be fixed β the output tells a reader to re-check rather than treating it as permanent design. The asymmetry it creates is the useful part: edit freely, remove deliberately. -
apns_targets_the_sandbox_gatewayexists because the failure is silent. Production device tokens are not valid on Apple's development gateway, so notifications are simply not delivered β an outage that reads like a client bug. A derived flag turns it into something a reviewer or a CI check can see. -
The credential variables are deliberately not marked
sensitive, for a reason distinct from the usual one. Elsewhere in this library a collection stays unmarked because a sensitive value cannot be afor_eachargument; these are single blocks, so that does not apply. The reason here is contagion: marking the object would makeapplication_modeandvapid_public_keysensitive too, and a derived flag built from a sensitive value is a sensitive bool Terraform refuses to emit. Sosensitive()is applied to the secret fields at the point of use β and the trade-off, that a.tfvarsvalue is not redacted, is stated rather than hidden. -
vapid_public_keyis left unwrapped because the provider does not mark it sensitive, and it is right not to: a VAPID public key exists to be handed to browsers. This library reached the same conclusion about public keys independently; here the provider got there first, and following its split is better than second-guessing it. -
credentials_are_in_terraform_state_in_plaintextis the caveat that outranks the markings. Every credential here is in state in the clear, so the effective access control is the state backend β which is why the permissions table carries a row that is not an Azure permission at all. -
Two validations are heuristics and say so in their own messages. A bundle ID without a dot, and a VAPID subject that is a bare email address, are both inferences rather than documented rules. Shipping a heuristic is fine; shipping one dressed as a rule is not.
| Concern | Secure default (empty call) | Opt-out (caller must type it) |
|---|---|---|
| Credentials | none required; presence-only outputs | supply them |
| Emitting a secret | never β presence only | β |
| False secrecy | state caveat stated, not implied away | β |
| A retired API | reported, not refused | set gcm_credential |
| A platform the provider cannot configure | named as a gap | β |
| A silent delivery failure | derived flag on Sandbox |
choose it |
| A destructive block removal | constant flag, and the bug named | β |
| A hub with no platforms | reported, not rejected | β |
| A PEM file where contents belong | rejected | β |
| A heuristic check | labelled as a heuristic | β |
| A public key | not redacted, following the provider | β |
| Tags a policy expects | β supported here | β |
- Before applying: an encrypted state backend.
- Before applying:
Production, notSandbox. - Before applying: FCM v1 out of band, if Android matters.
- Before removing a credential block: it replaces the hub.
- Before destroying: device registrations do not come back.
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the source to a tag β
?ref=v1.0.0β never a branch. - Plan-only from here. A human applies from CI.
- π Confirm the state backend is encrypted and access-controlled (example 9).
- π΄ Assert
configures_the_retired_fcm_legacy_credentialisfalse(example 5). β οΈ Assertapns_targets_the_sandbox_gatewayisfalsein production (example 3).β οΈ Assertconfigures_no_push_platformisfalsefor a live hub (example 1).- π΄ Record the out-of-band FCM v1 configuration somewhere durable (example 5).
β οΈ Never remove a credential block casually (example 7).
terraform validate and terraform fmt -check are the offline gate. They confirm:
nameandlocationare non-empty, andname,namespace_nameandresource_group_nameare names rather than Resource IDs;apns_credential.application_modeis exactlyProductionorSandbox;apns_credential.tokendoes not contain the PEM markers, is not a.p8filename, and is non-empty;apns_credential.bundle_idcontains a dot (a labelled heuristic), andkey_id/team_idare non-empty;browser_credential's three fields are non-empty, its subject is not a bare email address (a labelled heuristic), neither VAPID key is a PEM block, and the two halves differ;gcm_credential.api_keyis non-empty;timeouts, when supplied, uses only the four keys the resource offers;- the module declares no
providerblock.
π‘ These were proved by evaluating the conditions in
terraform consoleinside the module β which does fire root-module variable validations, unliketerraform validateon a calling configuration. Six.tfvarsfiles were needed, grouped so one fault could not mask another: a token containing PEM markers would also fire the filename check, so those two were isolated.
β Each check was proved with a value that must fail, and the legal call β a hub with both a
ProductionAPNS credential and a Web Push credential β was confirmed to produce no errors at all.
β Every derived output was evaluated rather than assumed. A
Sandboxhub with a legacy FCM key reportsapns_targets_the_sandbox_gateway = trueandhas_gcm_credential = true; a credential-free hub reportsconfigures_no_push_platform = true; the production call reportsfalsefor all three.
π΄ What the gate cannot check: whether the namespace is of type
NotificationHub, whetherlocationmatches the namespace, whether the APNS token is a valid key, whether the bundle ID matches a real Apple application, or whether FCM v1 has been configured out of band.
What only plan and apply exercise:
- whether the namespace exists and is of the right type;
- whether Apple and Google accept the credentials β Azure validates them at configuration time.
What no Terraform command checks at any stage:
- π΄ whether Android push works, since it is configured elsewhere (example 5);
β οΈ whether the APNS gateway matches the app build (example 3);- π who can read the state file (example 9);
β οΈ whether device registrations were lost by a replacement (examples 7, 11).
Outputs:
apns_targets_the_sandbox_gateway = false
configures_no_push_platform = false
configures_the_retired_fcm_legacy_credential = false
credentials_are_in_terraform_state_in_plaintext = true
fcm_v1_cannot_be_configured_through_this_provider = true
has_apns_credential = true
has_browser_credential = true
has_gcm_credential = false
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-push-prod/providers/Microsoft.NotificationHubs/namespaces/nhn-push-prod/notificationHubs/nh-exampleapp"
location = "eastus2"
name = "nh-exampleapp"
namespace_name = "nhn-push-prod"
only_three_push_platforms_are_configurable_here = true
removing_a_credential_block_replaces_the_hub = true
resource_group_name = "rg-push-prod"
β The three assertions worth automating all read
false: not on the sandbox gateway, not without a platform, and not using the retired Android credential (examples 1, 3, 5).
π No credential appears anywhere in that output, by design β only
has_*presence flags (example 9).
π΄
has_gcm_credential = falsealongsidefcm_v1_cannot_be_configured_through_this_provider = trueis the honest picture: Android is not configured here, and it cannot be. That does not mean Android push is broken β it means it is managed somewhere Terraform cannot see (example 5).
π΄ The four
trueconstants are facts, not statuses.credentials_are_in_terraform_state_in_plaintextdoes not mean something is misconfigured; it means check the backend.
| Symptom | Cause | Fix |
|---|---|---|
| iOS notifications silently undelivered | application_mode = "Sandbox". |
Use Production (example 3). |
| Azure rejects the APNS credential | The token includes PEM markers. | Send the contents only (example 3). |
Plan rejects apns_credential.token |
PEM markers, or a filename. | See example 3. |
Plan rejects bundle_id |
No dot β probably a team or key ID. | Reverse-DNS (example 3). |
| Plan rejects a VAPID key | A PEM block was pasted. | base64url (example 4). |
Plan rejects subject |
A bare email address. | Use mailto: (example 4). |
| A VAPID rotation destroyed the hub | browser_credential is force-new. |
Plan it as a migration (examples 4, 7). |
| Android notifications fail | FCM legacy is retired. | Configure FCM v1 out of band (example 5). |
| No FCM v1 arguments exist | Provider gap. | Portal, REST or SDK (example 5). |
| No WNS / ADM / Xiaomi arguments | Provider gap. | Out of band (example 6). |
| Removing a credential replaced the hub | A documented Azure SDK bug. | Expected (example 7). |
| Every device stopped receiving | The hub was replaced. | Registrations do not return (examples 7, 11). |
| Apply fails: namespace not found | Wrong namespace or resource group name. | Source all three from the namespace (example 8). |
| A secret appeared in a plan review | It was in a .tfvars, not the resource. |
Use a secret store (examples 9, 10). |
| An import proposes setting credentials | Azure does not return them. | Supply from the original store (example 11). |
Wanted prevent_destroy |
lifecycle is not valid inside a module block. |
Lock the namespace (example 11). |
azurerm_notification_hubβ provider documentation, including the credential blocks of examples 3β5 and the Azure SDK recreation bug of example 7.- Azure Notification Hubs and Google Firebase Cloud Messaging migration β the FCM legacy retirement of example 5, stated by Microsoft.
- FCM migration using the REST API and using the Azure SDKs β the out-of-band routes for FCM v1 (example 5).
- Microsoft.NotificationHubs notificationHubs β the eight ARM credential kinds of example 6.
- Configure APNS settings for a notification hub β the token-based credential and gateway of example 3.
- Sibling modules:
terraform-azurerm-notification-hub-namespace(the parent β the SKU, region and resilience decisions of examples 2 and 13),terraform-azurerm-notification-hub-authorization-rule(the shared access keys of example 2),terraform-azurerm-resource-group,terraform-azurerm-role-assignments. - This module's
SCOPE.md.
π "Infrastructure as Code should be standardized, consistent, and secure."