Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

☁️ Azure Iothub Dps Terraform Module

Provisions a single Azure IoT Hub Device Provisioning Service (DPS) β€” the zero-touch onboarding front door that assigns devices to your IoT hubs β€” hardened by default and targeting hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources

🧩 Overview

  • ☁️ Creates one azurerm_iothub_dps (the keystone this) β€” an IoT Hub Device Provisioning Service in a resource group you already own.
  • 🎚️ Renders the mandatory sku block (tier + capacity) with a sane, minimal default so the empty call is valid.
  • πŸ”— Links one or more IoT hubs via inline linked_hub blocks driven by a keyed map, with the allocation policy (Hashed / GeoLatency / Static) that decides how devices are spread across them.
  • πŸ›‘οΈ Constrains reachable source ranges via inline ip_filter_rule blocks driven by a keyed map (rule name = map key).
  • πŸ”’ Ships locked down: public_network_access_enabled = false, so the empty call yields a private service.
  • 🏷️ Carries the universal tags + timeouts tail.

πŸ’‘ Why it matters: DPS is how devices bootstrap themselves onto the right IoT hub without hard-coded connection strings. Getting its exposure, allocation policy, and hub links right β€” securely, and as reviewable code β€” is the difference between a fleet that self-registers safely and one that leaks a provisioning endpoint to the open internet.

❀️ Support this project

If this module saves you time, a little support goes a long way:

πŸ—ΊοΈ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"] -->|"resource_group_name + location"| dps["terraform-azurerm-iothub-dps"]
  hub["terraform-azurerm-iothub (sibling)"] -->|"connection_string via linked_hub"| dps
  dps -->|"id_scope + host names"| devices["provisioned devices"]
  dps -->|"allocates devices to"| hub
  eg["enrollment groups (sibling module)"] -->|"registers against id_scope"| dps
  pe["terraform-azurerm-private-endpoint (sibling)"] -->|"private access to id"| dps
  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#002236,color:#fff;
  classDef ext fill:#eef2f7,stroke:#b8c4d0,color:#1b2733;
  class dps me;
  class hub keystone;
  class rg,devices,eg,pe ext;
Loading

🧬 What this module builds

flowchart TB
  subgraph inputs["Inputs"]
    v1["name / resource_group_name / location"]
    v2["sku {name, capacity}"]
    v3["linked_hubs (keyed map)"]
    v4["ip_filter_rules (keyed map)"]
    v5["public_network_access_enabled = false"]
  end
  this["azurerm_iothub_dps.this"]
  subgraph blocks["Inline blocks"]
    b1["sku"]
    b2["linked_hub (per map entry)"]
    b3["ip_filter_rule (per map entry)"]
  end
  subgraph outputs["Outputs"]
    o1["id"]
    o2["name"]
    o3["id_scope"]
    o4["device_provisioning_host_name"]
    o5["service_operations_host_name"]
  end
  v1 --> this
  v2 --> this
  v3 --> this
  v4 --> this
  v5 --> this
  this --> b1
  this --> b2
  this --> b3
  this -->|"emits"| o1
  this -->|"emits"| o2
  this -->|"emits"| o3
  this -->|"emits"| o4
  this -->|"emits"| o5
  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#002236,color:#fff;
  classDef ext fill:#eef2f7,stroke:#b8c4d0,color:#1b2733;
  class this keystone;
  class b1,b2,b3 me;
  class v1,v2,v3,v4,v5,o1,o2,o3,o4,o5 ext;
Loading

Resource inventory

Element Kind Cardinality
azurerm_iothub_dps.this keystone resource 1
sku inline block exactly 1 (rendered from the sku object)
linked_hub inline block 0..N (one per linked_hubs entry)
ip_filter_rule inline block 0..N (one per ip_filter_rules entry)
timeouts inline block 0..1

βœ… Provider / Versions

Requirement Value
Terraform floor >= 1.12.0
Provider hashicorp/azurerm, pinned ~> 4.0
Provider block None in this module β€” the caller configures provider "azurerm" { features {} }, auth, and subscription

Schema notes that bite (verified against the live provider schema):

  • name, resource_group_name, and location are immutable β€” changing any of them forces replacement of the service.
  • data_residency_enabled is immutable β€” changing it forces a new resource, and enabling it removes the cross-region (geo-paired) disaster-recovery replica.
  • sku.name only accepts S1; there is no other Device Provisioning Service tier.
  • public_network_access_enabled defaults to true in the provider; this module deliberately defaults it to false.
  • linked_hub.connection_string is a secret and is marked sensitive by the provider β€” provision it out of band and pass a reference, never a literal.
  • allocation_policy only affects behavior once more than one hub is linked.

πŸ”‘ Required Azure RBAC Roles / Permissions

  • Contributor on the target resource group, or a custom role granting Microsoft.Devices/provisioningServices/* at the resource-group scope (create / read / update / delete the service and its inline configuration).
  • Linking a hub requires a valid IoT hub connection string; no additional role on the DPS is needed beyond the write permission above.

Azure Prerequisites

  • An existing resource group in a supported US Azure region.
  • The Microsoft.Devices resource provider registered on the target subscription.
  • For each linked hub: an existing IoT hub and a shared-access-policy connection string with registry write (and service) permissions, supplied out of band (for example via a Key Vault reference).
  • The caller configures the provider "azurerm" { features {} } block, auth, and subscription; this module declares none of these.

πŸ“ Module Structure

terraform-azurerm-iothub-dps/
β”œβ”€β”€ providers.tf     # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
β”œβ”€β”€ variables.tf     # deeply-typed object() schemas + secure defaults + tags/timeouts tail
β”œβ”€β”€ main.tf          # keystone azurerm_iothub_dps.this; dynamic sku/linked_hub/ip_filter_rule/timeouts
β”œβ”€β”€ outputs.tf       # id first, then name, id_scope, and the two host names
β”œβ”€β”€ README.md        # this file
β”œβ”€β”€ SCOPE.md         # the cross-module contract
β”œβ”€β”€ LICENSE          # MIT
└── .gitignore       # canonical library ignore set

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-prod"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"
}

ℹ️ The empty call produces a private service (public_network_access_enabled = false) on the S1 SKU with capacity 1. The caller owns the provider, auth, subscription, and the mandatory features {} block. Pin ?ref=v1.0.0 β€” never a branch.

πŸ”Œ Cross-Module Contract

Consumes

Input Type From
resource_group_name string terraform-azurerm-resource-group output name
location string caller / resource group location
linked_hubs[*].connection_string string (secret) an IoT hub owner / shared-access-policy connection string, provisioned out of band
linked_hubs[*].location string the linked IoT hub's region

Emits

Output Description Consumed by
id Service Resource ID (first) diagnostics, private endpoint, role assignments
name Service name diagnostics / tagging conventions
id_scope DPS ID Scope devices present to the global provisioning endpoint enrollment groups, device firmware
device_provisioning_host_name Device-facing provisioning endpoint hostname device firmware / provisioning config
service_operations_host_name Service-operations endpoint hostname management-plane tooling

πŸ“š Example Library

1 Β· Minimal, private service
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-prod"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"
}

πŸ”’ No sku needed β€” it defaults to { name = "S1", capacity = 1 }. Public network access is off.

2 Β· Scaling registration throughput (SKU capacity)
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-prod"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"

  sku = {
    name     = "S1"
    capacity = 3
  }
}

πŸ’‘ Each unit raises registration throughput; S1 is the only tier the service offers, so only capacity moves.

3 Β· Link a single IoT hub
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-prod"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"

  linked_hubs = {
    primary = {
      connection_string = var.hub_primary_connection_string # provisioned out of band
      location          = "eastus"
    }
  }
}

πŸ”’ connection_string is a secret. Pass a reference (e.g. a Key Vault-sourced value), never a committed literal. The provider marks the field sensitive.

4 Β· Multiple hubs with Hashed allocation and weights
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-prod"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"

  allocation_policy = "Hashed"

  linked_hubs = {
    east = {
      connection_string       = var.hub_east_connection_string
      location                = "eastus"
      apply_allocation_policy = true
      allocation_weight       = 2
    }
    west = {
      connection_string       = var.hub_west_connection_string
      location                = "westus2"
      apply_allocation_policy = true
      allocation_weight       = 1
    }
  }
}

πŸ’‘ With Hashed, allocation_weight biases how many devices land on each hub β€” here roughly 2:1 toward east.

5 Β· Lowest-latency allocation (GeoLatency)
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-global"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"

  allocation_policy = "GeoLatency"

  linked_hubs = {
    east = {
      connection_string = var.hub_east_connection_string
      location          = "eastus"
    }
    west = {
      connection_string = var.hub_west_connection_string
      location          = "westus2"
    }
  }
}

ℹ️ GeoLatency assigns each device to whichever linked hub is lowest-latency for it β€” weights are ignored.

6 Β· Pin devices to one hub (Static)
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-fixed"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"

  allocation_policy = "Static"

  linked_hubs = {
    only = {
      connection_string = var.hub_primary_connection_string
      location          = "eastus"
    }
  }
}

ℹ️ Static pins allocation to a fixed hub β€” useful for single-region fleets or migrations.

7 Β· Restrict source ranges with IP filter rules
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-prod"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"

  # opt in to public reachability, then constrain it
  public_network_access_enabled = true

  ip_filter_rules = {
    "allow-corp" = {
      ip_mask = "203.0.113.0/24"
      action  = "Accept"
    }
    "deny-rest" = {
      ip_mask = "0.0.0.0/0"
      action  = "Reject"
    }
  }
}

⚠️ Turning public_network_access_enabled on opens the service to the internet. Always pair it with ip_filter_rules (or prefer a private endpoint).

8 Β· Target device vs service endpoints separately
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                          = "dps-fleet-prod"
  resource_group_name           = "rg-iot-prod"
  location                      = "eastus"
  public_network_access_enabled = true

  ip_filter_rules = {
    "devices-from-field" = {
      ip_mask = "198.51.100.0/24"
      action  = "Accept"
      target  = "deviceApi"
    }
    "ops-from-office" = {
      ip_mask = "203.0.113.10/32"
      action  = "Accept"
      target  = "serviceApi"
    }
  }
}

πŸ’‘ target scopes a rule to deviceApi, serviceApi, or all β€” letting devices and operators come from different networks.

9 Β· Data residency (single-region, no geo-pair)
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                   = "dps-fleet-regional"
  resource_group_name    = "rg-iot-prod"
  location               = "eastus2"
  data_residency_enabled = true
}

⚠️ data_residency_enabled is immutable and disables the cross-region disaster-recovery replica. Decide before first apply β€” changing it later forces replacement.

10 Β· Security-hardened variant (private, explicit)
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                          = "dps-fleet-prod"
  resource_group_name           = "rg-iot-prod"
  location                      = "eastus"
  public_network_access_enabled = false # already the default; stated for reviewability

  tags = {
    environment = "prod"
    data_class  = "restricted"
    owner       = "iot-platform"
  }
}

πŸ”’ The most locked-down posture: no public reachability. Compose a private endpoint for connectivity.

11 Β· Many linked hubs via for_each at scale
locals {
  regional_hubs = {
    east    = { connection_string = var.hub_east_cs, location = "eastus" }
    east2   = { connection_string = var.hub_east2_cs, location = "eastus2" }
    west    = { connection_string = var.hub_west_cs, location = "westus2" }
    central = { connection_string = var.hub_central_cs, location = "centralus" }
  }
}

module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-global"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"
  allocation_policy   = "GeoLatency"

  linked_hubs = local.regional_hubs
}

πŸ’‘ The map key is a stable handle; adding or removing one hub never re-indexes the others.

12 Β· Tags and custom timeouts
module "dps" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"

  name                = "dps-fleet-prod"
  resource_group_name = "rg-iot-prod"
  location            = "eastus"

  tags = {
    environment = "prod"
    cost_center = "iot-1042"
  }

  timeouts = {
    create = "30m"
    delete = "30m"
  }
}

ℹ️ Omit timeouts to inherit provider defaults; set only the phases you need.

13 Β· Consuming resource-group and iothub sibling outputs
module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-iot-prod"
  location = "eastus"
}

module "hub" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub.git?ref=v1.0.0"
  name                = "iot-fleet-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  sku                 = { name = "S1", capacity = 1 }
}

module "dps" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"
  name                = "dps-fleet-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  linked_hubs = {
    primary = {
      connection_string = var.hub_owner_connection_string # sourced from a secret store, not the hub module output
      location          = module.rg.location
    }
  }
}

πŸ”’ The hub's owner connection string is a secret provisioned out of band β€” do not wire a plaintext secret output between modules.

14 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

# 1) Resource group
module "rg" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
  name     = "rg-iot-prod"
  location = "eastus"

  tags = { environment = "prod", workload = "iot" }
}

# 2) The IoT hub devices will be allocated to
module "hub" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub.git?ref=v1.0.0"
  name                = "iot-fleet-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  sku                 = { name = "S1", capacity = 1 }

  tags = { environment = "prod", workload = "iot" }
}

# 3) The Device Provisioning Service, linking the hub
module "dps" {
  source              = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-dps.git?ref=v1.0.0"
  name                = "dps-fleet-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  allocation_policy             = "Hashed"
  public_network_access_enabled = false

  linked_hubs = {
    primary = {
      connection_string = var.hub_owner_connection_string # from Key Vault, provisioned out of band
      location          = module.rg.location
    }
  }

  tags = { environment = "prod", workload = "iot" }
}

# Devices bootstrap against this value
output "dps_id_scope" {
  value = module.dps.id_scope
}

πŸ—οΈ Resource group β†’ IoT hub β†’ DPS. Devices present module.dps.id_scope to the global provisioning endpoint and are allocated to the linked hub. The hub connection string stays a secret, provisioned out of band.

πŸ“₯ Inputs

Identity (required) β€” name, resource_group_name, location (all immutable / force-new).

Core (optional, secure defaults) β€” sku ({ name = "S1", capacity = 1 }), allocation_policy ("Hashed"), data_residency_enabled (false, immutable), public_network_access_enabled (false).

Repeating blocks (keyed maps) β€” linked_hubs ({}), ip_filter_rules ({}).

Universal tail β€” tags ({}), timeouts (null).

Full object() schemas
variable "name" { type = string } # immutable
variable "resource_group_name" { type = string } # immutable
variable "location" { type = string } # immutable

variable "sku" {
  type = object({
    name     = optional(string, "S1")  # only "S1" is offered
    capacity = optional(number, 1)      # >= 1
  })
  default = {}
}

variable "allocation_policy" {
  type    = string
  default = "Hashed" # Hashed | GeoLatency | Static
}

variable "data_residency_enabled" {
  type    = bool
  default = false # immutable; disables the geo-paired DR replica
}

variable "public_network_access_enabled" {
  type    = bool
  default = false # secure default (provider default is true)
}

variable "linked_hubs" {
  type = map(object({
    connection_string       = string          # secret; provider marks it sensitive
    location                = string
    apply_allocation_policy = optional(bool)   # provider default: true
    allocation_weight       = optional(number) # provider default: 1
  }))
  default = {}
}

variable "ip_filter_rules" {
  type = map(object({          # map key is the rule name
    ip_mask = string           # CIDR
    action  = string           # Accept | Reject
    target  = optional(string) # all | deviceApi | serviceApi
  }))
  default = {}
}

variable "tags" {
  type    = map(string)
  default = {}
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    update = optional(string)
    delete = optional(string)
  })
  default = null
}

🧾 Outputs

Output Description Notes
id Service Resource ID emitted first
name Service name β€”
location Azure region, in the canonical form Azure uses. Read from the resource, not var.location.
id_scope DPS ID Scope devices present at provisioning time not a secret
device_provisioning_host_name Device-facing provisioning endpoint hostname β€”
service_operations_host_name Service-operations endpoint hostname β€”
resource_group_name Resource group holding the service Force-new.
sku_name / capacity Tier (always S1) and provisioned units Capacity is 1–200 and billed continuously.
billed_whether_used Constant true A DPS is provisioned throughput, not consumption.
allocation_policy Hashed, GeoLatency or Static Inert until more than one hub is linked.
public_network_access_enabled Public reachability, stated positively Module default false; provider default true.
data_residency_enabled Provisioning data pinned to the region Force-new, and it removes the geo-paired DR replica.
linked_hub_count / has_no_linked_hubs How many hubs devices can be assigned to A service with none deploys cleanly and provisions nothing.
linked_hub_locations Map: hub key β†’ that hub's region Linked hubs need not share this service's region.
linked_hubs_excluded_from_allocation Hubs that will receive no devices apply_allocation_policy = false, or weight 0 under Hashed.
accepts_iot_hub_connection_strings Constant true There is no identity-based alternative on this resource.
ip_filter_rule_count Number of IP filter rules
publicly_reachable_from_anywhere Public access on, no rules Every address on the internet reaches both endpoints.
ip_filter_rules_are_inert Rules configured while public access is off Accepted, stored, restricting nothing.
id_scope_changes_if_the_service_is_replaced Constant true A replacement is a fleet-wide reconfiguration.

ℹ️ No connection strings are emitted. Linked-hub connection strings are inputs only.

πŸ”΄ id_scope is the fact with the longest reach. Azure issues it at creation and every provisioned device carries it in its own configuration. name, resource_group_name, location and data_residency_enabled are all force-new, so changing any one of them replaces the service, issues a new ID Scope, and silently breaks every device already in the field. A plan that reads as one resource replacement is a fleet-wide reconfiguration.

⚠️ The linked-hub connection string is a secret whose drift is invisible. The provider marks it sensitive, which redacts plan output and does not encrypt state β€” the value sits in the state file in plaintext, so an encrypted, access-controlled backend is the control that matters. Azure returns the key portion as **** rather than the real value and the provider suppresses the difference, so rotating the hub's shared-access key out of band produces no drift here: Terraform has nothing real to compare against.

⚠️ Marking linked_hubs sensitive makes everything derived from it sensitive too β€” a plain count included β€” and Terraform refuses to emit a sensitive output without an explicit mark. The counts and region maps above are unwrapped with nonsensitive() deliberately; the connection strings are never derived from and never emitted.

🧠 Architecture Notes

  • One keystone, no owned children. This module manages exactly one azurerm_iothub_dps.this. DPS certificates and shared-access policies are separate sibling modules on purpose; this module owns only the service and its inline sku, linked_hub, and ip_filter_rule blocks.
  • Keyed maps drive stable rendering. linked_hubs and ip_filter_rules are keyed maps rendered as dynamic blocks with for_each, so adding or removing one entry never re-indexes the rest. The ip_filter_rules key is used as the rule name.
  • Force-new fields bite. name, resource_group_name, location, and data_residency_enabled are immutable β€” Terraform replaces the service if you change any of them. Decide on data residency before the first apply.
  • Secret handling. linked_hub.connection_string is marked sensitive by the provider, so it is redacted in plan output and state. The linked_hubs variable is intentionally not declared sensitive at the module level so the keyed map can drive for_each β€” provision the connection strings out of band and pass references, never literals. The module never emits a connection string.
  • features {} dependence. The module carries no provider {} block. The service will not plan without a caller-side provider "azurerm" { features {} } block; that is expected and lives in the root module.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Public network access public_network_access_enabled = false set to true
Source-range restriction no rules, but exposure is off by default set public_network_access_enabled = true and add ip_filter_rules
Secret material connection strings are inputs, never outputs; provider-sensitive β€”
Data residency data_residency_enabled = false (keeps the geo-paired DR replica) set to true (immutable)

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the module at ?ref=v1.0.0 β€” never a branch.
  • This is plan-only during authoring. A human runs terraform plan / apply against real credentials from CI.
  • The caller supplies provider "azurerm" { features {} }, auth, and subscription.

πŸ§ͺ Testing

The offline proof gate runs without any cloud calls:

  • terraform init -backend=false β€” resolves the provider without configuring a backend.
  • terraform validate β€” proves the configuration is type-correct against the pinned provider schema; the deeply-typed object() inputs and the validation {} enum guards surface bad values here, before any ARM call.
  • terraform fmt -check β€” enforces canonical formatting.

Only terraform plan (run by a human, against real credentials, from CI) exercises the ARM API. This module is never applied during authoring.

πŸ’¬ Example Output

$ terraform output
id                            = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-iot-prod/providers/Microsoft.Devices/provisioningServices/dps-fleet-prod"
name                          = "dps-fleet-prod"
id_scope                      = "0ne00ABCDEF"
device_provisioning_host_name = "dps-fleet-prod.azure-devices-provisioning.net"
service_operations_host_name  = "dps-fleet-prod.azure-devices-provisioning.net"

πŸ” Troubleshooting

Symptom Cause Fix
Provider configuration not present / init fails on features The root module is missing provider "azurerm" { features {} } Add the provider block with features {} to the caller's root module.
Plan shows the whole service being replaced You changed name, resource_group_name, location, or data_residency_enabled These are immutable. Keep them stable, or accept replacement deliberately.
sku.name must be "S1" at plan A non-S1 SKU was passed The Device Provisioning Service only offers S1; set sku.name = "S1" (or omit sku).
allocation_policy must be one of: Hashed, GeoLatency, Static An invalid policy value Use one of the three legal values.
Devices cannot reach the provisioning endpoint public_network_access_enabled = false (default) with no private path Compose a private endpoint, or set public access true and add ip_filter_rules.
Allocation weights appear ignored allocation_policy is GeoLatency or Static, or only one hub is linked Weights apply only to Hashed with more than one linked hub.
Connection string visible where you did not expect It was passed as a literal in code Provision it out of band (Key Vault reference); the provider redacts it in plan/state but committed literals still leak in source.

πŸ”— Related Docs

  • Provider resource: azurerm_iothub_dps
  • Sibling: terraform-azurerm-iothub β€” the IoT hub devices are allocated to.
  • Sibling: terraform-azurerm-iothub-dps-certificate β€” DPS certificate management.
  • azurerm_iothub_dps_shared_access_policy β€” DPS shared-access policies. Not yet available as a module in this suite; declare the resource directly for now.
  • Sibling: terraform-azurerm-private-endpoint β€” private connectivity to this service's id.
  • Sibling: terraform-azurerm-monitor-diagnostic-setting β€” platform logs/metrics against this service's id.
  • This module's SCOPE.md β€” the cross-module contract.

πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."