Configures the storage account, blob container, authentication and notification behaviour that back an IoT Hub's device file-upload feature, targeting
hashicorp/azurerm ~> 4.0.
- π¦ Points an IoT Hub at the storage account and blob container that receive device file uploads.
- π Selects how the hub authenticates to that storage account β account key, or a managed identity.
- π Configures the optional file-upload notification queue: lifetime, lock duration, delivery attempts.
- β±οΈ Bounds the SAS URI lifetime handed to each uploading device.
- π§Ύ Emits the configuration as data plus a set of derived flags for the traps that no plan reveals.
π‘ Why it matters: file upload is the one IoT Hub feature where a storage account key is unavoidable, where
terraform destroydoes not undo what was applied, and where rotating that key produces no diff. This module cannot remove those properties β the provider and the platform decide them β so it names each one in an output rather than letting it be discovered in production.
If this module saves you time, a little support goes a long way:
- β Star the repository on GitHub.
- π Connect on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Every module in the IoT Hub family hangs off one hub. This module is the ...-iothub-file-upload node β and note it is the only one joining the hub by Resource ID rather than by name.
flowchart TB
RG["terraform-azurerm-resource-group"]
HUB["terraform-azurerm-iothub"]
SA["terraform-azurerm-storage-account"]
UAI["terraform-azurerm-user-assigned-identity"]
RA["terraform-azurerm-role-assignments"]
EPEH["terraform-azurerm-iothub-endpoint-eventhub"]
ROUTE["terraform-azurerm-iothub-route"]
FBROUTE["terraform-azurerm-iothub-fallback-route"]
ENRICH["terraform-azurerm-iothub-enrichment"]
FUPLOAD["terraform-azurerm-iothub-file-upload"]
RG -->|"name feeds resource_group_name"| HUB
UAI -->|"id feeds the hubs identity"| HUB
HUB -->|"name feeds iothub_name"| ROUTE
HUB -->|"name feeds iothub_name"| FBROUTE
HUB -->|"name feeds iothub_name"| ENRICH
HUB -->|"id feeds iothub_id, an ID not a name"| FUPLOAD
EPEH -->|"name feeds endpoint_names"| ROUTE
EPEH -->|"name feeds endpoint_names"| ENRICH
SA -->|"container name and connection string"| FUPLOAD
RA -->|"Storage Blob Data Contributor on the account"| FUPLOAD
HUB -.->|"its inline inputs conflict with all four"| ENRICH
classDef batch fill:#0078D4,stroke:#004578,color:#ffffff
classDef anchor fill:#004578,stroke:#002d4d,color:#ffffff
classDef ext fill:#f0f3f7,stroke:#9aa7b5,color:#1b2733
class ENRICH,FUPLOAD batch
class HUB anchor
class RG,SA,UAI,RA,EPEH,ROUTE,FBROUTE ext
flowchart TB
PARENT["iothub_id<br/>a RESOURCE ID, force new, and the only one that is"]
SECRET["connection_string<br/>required, sensitive, a storage account key"]
CONT["container_name"]
AUTH["authentication_type<br/>identity_id"]
NOTIF["notifications_enabled<br/>default_ttl, lock_duration, max_delivery_count"]
SAS["sas_ttl<br/>the device leg, never inert"]
T["azurerm_iothub_file_upload.this"]
OUT["id, which IS the hubs own id<br/>iothub_name<br/>uses_identity_based_authentication"]
SILENT["destroy_leaves_the_configuration_live_on_the_hub<br/>a_rotated_storage_key_produces_no_plan_diff"]
CRED["plan_access_is_credential_access<br/>identity_based_authentication_does_not_remove_the_storage_key"]
INERT["notification_settings_configured_but_inert"]
PARENT --> T
SECRET --> T
CONT --> T
AUTH --> T
NOTIF --> T
SAS --> T
T --> OUT
T --> SILENT
SECRET --> CRED
AUTH --> CRED
NOTIF --> INERT
classDef keystone fill:#004578,stroke:#002d4d,color:#ffffff
classDef io fill:#0078D4,stroke:#004578,color:#ffffff
classDef note fill:#f0f3f7,stroke:#9aa7b5,color:#1b2733
class T keystone
class PARENT,SECRET,CONT,AUTH,NOTIF,SAS,OUT io
class SILENT,CRED,INERT note
| Resource | Cardinality | Notes |
|---|---|---|
azurerm_iothub_file_upload.this |
exactly one per hub | A facet of the hub, not a child collection. Its Resource ID is the hub's. |
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/azurerm |
~> 4.0 |
| Provider block | None in this module β the caller configures the provider, including the mandatory features {} block. |
Schema notes that bite β each verified against the live provider schema and source:
- π΄ The record's Resource ID is the hub's Resource ID, unchanged. There is no
/fileUploadsegment. Two blocks against one hub collide on one object with nothing to conflict on, and the plan never settles. - π΄
terraform destroyis a no-op against Azure. The delete path reads the hub and writes it back unmodified; the file-upload configuration stays live. - π΄ A rotated storage key produces no plan diff. Azure masks the key and the provider suppresses the diff on that portion.
- π΄
connection_stringis required even whenauthentication_typeisidentityBased. Devices always receive a SAS URI generated from it. β οΈ identity_idmay only be set withidentityBasedβ enforced by the provider at apply time, and by this module at parse time.β οΈ The first-apply guard is conditional. It is skipped by a provider feature flag, and does not trip on a half-configured hub.β οΈ iothub_idis the only force-new argument. Everything else updates in place.β οΈ Notags, nolocation.
| Principal | Permission | Scope | Why |
|---|---|---|---|
| The Terraform identity | Contributor, or a custom role with Microsoft.Devices/iotHubs/write |
The IoT Hub | File upload is a property of the hub, so configuring it is a write to the hub |
| The Terraform identity | Microsoft.Devices/iotHubs/read |
The IoT Hub | Refresh, plan, and the first-apply guard |
| The hub's managed identity | Storage Blob Data Contributor |
The storage account | Only when authentication_type is identityBased |
π΄ None of IoT Hub's four built-in roles grants the management-plane write. IoT Hub Data Contributor, Data Reader, Registry Contributor and Twin Contributor are all data-plane β Microsoft states they "grant access to resources like devices and twin but they don't grant access to the IoT Hub resource".
π΄ Microsoft is explicit about which storage role to use: "select Storage Blob Data Contributor. (Don't select Contributor or Storage Account Contributor.)" The broader roles do not grant the blob data-plane access the hub actually needs.
π΄ Plan access is credential access here. connection_string is a required input carrying a storage account key. It sits in state in plaintext; sensitive = true redacts plan output and does not encrypt state. Grant plan rights only to principals you would hand the storage key to, and keep state in an encrypted, access-controlled backend β never a local file in a repository.
Microsoft.Devices/iotHubs/write is hub-level, so the same grant also permits changing the hub's SKU, routing, network rules and identity.
Microsoft.Devicesregistered on the subscription.- An existing IoT Hub, referenced by its Resource ID.
- π΄ That hub's
file_uploadinput left unset. Choose one model per hub. - An existing storage account and blob container. This module creates neither.
- π΄ The storage account in the same subscription as the hub. Microsoft documents this requirement; nothing here can check it.
- π΄ For
identityBased: the hub's identity already holdingStorage Blob Data Contributoron the storage account, assigned before the first apply, with a few minutes allowed for propagation. - No resource group or region to choose for this record.
terraform-azurerm-iothub-file-upload/
βββ providers.tf # required_version + pinned azurerm; no provider block
βββ variables.tf # 11 typed inputs, 14 validations
βββ main.tf # derived locals + the single azurerm_iothub_file_upload.this
βββ outputs.tf # 29 outputs: configuration, derived flags, and the traps
βββ README.md # this file
βββ SCOPE.md # the cross-module contract
βββ LICENSE # MIT
βββ .gitignore
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string # provisioned out of band
container_name = "device-uploads"
}π The caller configures
provider "azurerm" { features {} }, authentication, and the state backend.connection_stringcarries a storage account key β pass a reference, never a literal.
Consumes
| Input | Type | Source module |
|---|---|---|
iothub_id |
string (Resource ID) |
terraform-azurerm-iothub β id |
container_name |
string |
terraform-azurerm-storage-container β name |
identity_id |
string (Resource ID) |
terraform-azurerm-user-assigned-identity β id |
connection_string |
string (sensitive) |
provisioned out of band β never emitted by a module here |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
Resource ID β the hub's own ID | Audit |
iothub_name |
Hub name, derived from the ID | The routing modules, which join by name |
uses_identity_based_authentication |
Whether the hub-to-storage leg uses an identity | check blocks |
plan_access_is_credential_access |
Constant true |
Security review |
1 Β· Minimal, key-based
The smallest call. authentication_type defaults to keyBased, notifications are off, and the SAS URI lives one hour.
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
}π The empty call is not the hardened call here, and this module does not pretend otherwise:
connection_stringis required by the provider, so there is no configuration of this resource that does not put a storage account key in state.
2 Β· Identity-based, user-assigned identity
The preferred posture where the prerequisites are met.
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
authentication_type = "identityBased"
identity_id = module.upload_identity.id
}
β οΈ The identity must be one of the hub's ownidentity_ids, and must already holdStorage Blob Data Contributoron the storage account.
3 Β· Identity-based with the hub's system-assigned identity
Omitting identity_id under identityBased is documented behaviour, not an oversight β Azure falls back to the hub's system-assigned identity.
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
authentication_type = "identityBased"
}
output "using_system_identity" {
value = module.file_upload.uses_the_hubs_system_assigned_identity
}βΉοΈ
uses_the_hubs_system_assigned_identityexists so this reads as a decision in the plan rather than as a missing field.
4 Β· Enabling upload notifications
Notifications are off by default. Turning them on activates the three queue settings.
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
notifications_enabled = true
default_ttl = "P1D"
lock_duration = "PT30S"
max_delivery_count = 25
}π‘
default_ttl = "P1D"here matches what the Azure portal shows as the default. The provider's default isPT1Hβ see example 5.
5 Β· Catching settings that cannot take effect
Tuning the notification queue while notifications are disabled is legal, shows up in the plan, and does nothing.
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
max_delivery_count = 25 # notifications_enabled is still false
}
check "notification_settings_take_effect" {
assert {
condition = !module.file_upload.notification_settings_configured_but_inert
error_message = "Notification settings were tuned but notifications_enabled is false, so they do nothing."
}
}
β οΈ Nothing rejects this combination. The flag is the only signal.
6 Β· Bounding the SAS URI lifetime
sas_ttl is the one setting here that is never inert, because the device leg is always SAS.
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
sas_ttl = "PT15M"
}π This bounds how long a leaked upload URI stays usable. It applies whether or not the hub itself uses a managed identity.
7 Β· Asserting the security posture
check "file_upload_uses_identity" {
assert {
condition = module.file_upload.uses_identity_based_authentication
error_message = "Hub-to-storage authentication must use a managed identity."
}
}
check "sas_uri_is_short_lived" {
assert {
condition = contains(["PT15M", "PT30M"], module.file_upload.sas_ttl)
error_message = "SAS URIs must expire within 30 minutes."
}
}
β οΈ Passing the first assertion does not mean the storage key is gone β see example 8.
8 Β· The limits of identity-based authentication
Worth surfacing in review, because the setting reads stronger than it is.
output "storage_key_still_present" {
value = module.file_upload.identity_based_authentication_does_not_remove_the_storage_key
}
output "plan_access_is_credential_access" {
value = module.file_upload.plan_access_is_credential_access
}π
authentication_typegoverns the hub-to-storage leg only. Devices always upload with a SAS URI that Azure generates fromconnection_string, so the account key stays in the connection string, in state, and in the device path regardless.
9 Β· Key rotation is invisible to Terraform
output "rotation_needs_its_own_step" {
value = module.file_upload.a_rotated_storage_key_produces_no_plan_diff
}
β οΈ Azure returns the account key masked and the provider compares the connection string with that portion excluded. After rotating the storage key,terraform planreports no changes β the hub keeps using the old key until something writes the endpoint again. Treat rotation as an out-of-band runbook step with a forced re-apply, not as drift a plan will catch.
10 Β· Destroy does not turn file upload off
output "destroy_is_a_no_op" {
value = module.file_upload.destroy_leaves_the_configuration_live_on_the_hub
}π΄ The provider's delete path reads the hub and writes it back unmodified.
terraform destroyremoves the record from state while the storage endpoint, connection string and notification flag stay live on the hub. To actually disable file upload, clear it on the hub itself.lifecycleblocks are not valid inside amoduleblock, so a caller cannot addprevent_destroyhere β where deletion must be prevented, use aCanNotDeletemanagement lock on the hub, remembering a lock prevents deletion rather than replacement.
11 Β· One per hub, and why
output "id_is_the_hub_id" {
value = module.file_upload.id == module.iothub.id # true
}π΄ The record's Resource ID is the hub's. A second block against the same hub produces the same ID, leaving Terraform with one object claimed twice and nothing to conflict on β an alternating diff that never converges rather than an error.
only_one_file_upload_configuration_per_hubstates the rule; there is nothing to key afor_eachon.
12 Β· Choosing the model: this module or the hub's inline input
terraform-azurerm-iothub exposes a file_upload input that does the same job. Using both against one hub causes spurious changes on every plan, and no validation can detect it, because the two halves live in different module blocks.
module "iothub" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub.git?ref=v1.0.0"
name = "iot-plant-eastus"
resource_group_name = module.resource_group.name
location = module.resource_group.location
sku = { name = "S1", capacity = 1 }
file_upload = null # managed by the module below instead
}
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
}βΉοΈ Setting
file_upload = nullexplicitly makes the chosen model visible in review rather than implied by omission.
13 Β· ποΈ End-to-end composition
A hub with an identity, a storage container to receive uploads, the role assignment the identity needs, file upload wired to both, and a route so telemetry has somewhere to go.
module "resource_group" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-iot-eastus"
location = "eastus"
}
module "upload_identity" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-user-assigned-identity.git?ref=v1.0.0"
name = "uai-iot-uploads"
resource_group_name = module.resource_group.name
location = module.resource_group.location
}
module "storage" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"
name = "stiotuploadseastus"
resource_group_name = module.resource_group.name
location = module.resource_group.location
}
# The identity needs blob DATA access - not Contributor, not Storage Account Contributor.
module "upload_role" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"
scope = module.storage.id
role_assignments = {
"hub-to-uploads" = {
role_definition_name = "Storage Blob Data Contributor"
principal_id = module.upload_identity.principal_id
}
}
}
module "iothub" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub.git?ref=v1.0.0"
name = "iot-plant-eastus"
resource_group_name = module.resource_group.name
location = module.resource_group.location
sku = { name = "S1", capacity = 1 }
identity = {
type = "UserAssigned"
identity_ids = [module.upload_identity.id]
}
file_upload = null # this composition uses the dedicated module below
}
module "file_upload" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-file-upload.git?ref=v1.0.0"
iothub_id = module.iothub.id
connection_string = var.storage_connection_string
container_name = "device-uploads"
authentication_type = "identityBased"
identity_id = module.upload_identity.id
notifications_enabled = true
sas_ttl = "PT15M"
depends_on = [module.upload_role]
}
module "telemetry_route" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-iothub-route.git?ref=v1.0.0"
name = "telemetry-to-events"
iothub_name = module.file_upload.iothub_name
resource_group_name = module.resource_group.name
routing_source = "DeviceMessages"
endpoint_names = ["events"]
enabled = true
}
# Deliberately NOT re-emitted: the connection string. Consumers read it from the
# same secret store this configuration read it from.
output "file_upload_id" {
value = module.file_upload.id
}
β οΈ depends_on = [module.upload_role]orders the role assignment first, but Microsoft notes the assignment takes a few minutes to propagate β a first apply can still fail on timing and succeed on retry. Seethe_identity_must_hold_storage_blob_data_contributor_before_the_first_apply.π‘
iothub_nameis emitted here precisely so the routing modules, which join the hub by name, can be wired from the same composition that joined it by ID.
Required (3): iothub_id, connection_string (sensitive), container_name.
Authentication (2): authentication_type, identity_id.
Notifications (4): notifications_enabled, default_ttl, lock_duration, max_delivery_count.
Device leg (1): sas_ttl. Tail (1): timeouts.
Full input schemas
| Name | Type | Default | Notes |
|---|---|---|---|
iothub_id |
string |
β | Resource ID. Force-new. Anchored to /providers/Microsoft.Devices/IotHubs/<name> with no trailing segment |
connection_string |
string |
β | sensitive = true. Must contain AccountName=; an IoT Hub connection string is rejected by name |
container_name |
string |
β | 3β63 chars, lowercase alphanumeric with single hyphens |
authentication_type |
string |
"keyBased" |
Exactly keyBased or identityBased, case-sensitive |
identity_id |
string |
null |
User-assigned identity Resource ID. Only valid with identityBased |
notifications_enabled |
bool |
false |
Gates the three settings below |
default_ttl |
string |
"PT1H" |
ISO 8601. Provider default differs from the portal's |
lock_duration |
string |
"PT1M" |
ISO 8601 |
max_delivery_count |
number |
10 |
Whole number, 1β100 |
sas_ttl |
string |
"PT1H" |
ISO 8601. Never inert |
timeouts |
object |
null |
All four operations |
| Output | Description | Notes |
|---|---|---|
id |
The record's Resource ID | Is the hub's own ID |
iothub_id / iothub_name |
Parent references | iothub_name derived from the ID |
container_name |
The upload container | |
authentication_type / uses_identity_based_authentication |
Hub-to-storage leg | |
identity_id / uses_the_hubs_system_assigned_identity |
Which identity | |
notifications_enabled / default_ttl / lock_duration / max_delivery_count |
Notification queue | |
sas_ttl |
Device SAS lifetime | Never inert |
notification_settings_are_inert |
Derived | |
notification_settings_configured_but_inert |
Derived | Only signal for a silent no-op |
the_resource_id_is_the_hubs_own_id |
Constant true |
|
only_one_file_upload_configuration_per_hub |
Constant true |
|
destroy_leaves_the_configuration_live_on_the_hub |
Constant true |
Read this |
a_rotated_storage_key_produces_no_plan_diff |
Constant true |
Read this |
identity_based_authentication_does_not_remove_the_storage_key |
Constant true |
|
plan_access_is_credential_access |
Constant true |
|
the_storage_key_is_not_emitted |
Constant true |
|
conflicts_with_the_inline_file_upload_on_the_iothub_module |
Constant true |
|
the_existence_check_can_be_disabled_by_a_provider_feature_flag |
Constant true |
|
storage_account_must_be_in_the_same_subscription_as_the_hub |
Constant true |
|
the_identity_must_hold_storage_blob_data_contributor_before_the_first_apply |
Constant true |
|
default_ttl_differs_from_the_portal_default |
Constant true |
|
has_no_tags_or_location_of_its_own |
Constant true |
|
only_the_iothub_id_is_force_new |
Constant true |
No output is sensitive, because no output is derived from the secret. Presence is not emitted either β connection_string is required, so a presence flag would be a constant true dressed up as information.
The identity of this resource is the identity of its parent. The provider sets the record's Terraform ID to the hub's Resource ID with no child segment. Everything awkward about this module follows from that: there is exactly one per hub, there is nothing to for_each over, import takes the hub's ID, and two blocks pointed at one hub produce an alternating diff instead of an error.
Three legs, and this resource configures one and a half of them. Device-to-hub is the hub's own authentication. Hub-to-storage is authentication_type. Device-to-storage is always a SAS URI generated from connection_string. That third leg is why the connection string is required in every configuration and why choosing identityBased is a real improvement that is nonetheless not the improvement it appears to be.
Two operations this module cannot make visible. Rotating the storage key produces no diff, because Azure masks the key and the provider excludes it from comparison. Destroying the resource produces no change on Azure, because the delete path writes the hub back unmodified. Both are silent, both are consequential, and both get a constant output rather than a sentence in a description.
The cross-field rule is enforced at parse time, one-directionally. A validation condition may only reference its own variable, so the identity_id / authentication_type pairing is carried on identity_id and the error message names both fields. The provider enforces the same rule in its create path, at apply time; this module simply moves the failure earlier.
Sensitivity is contained by deriving nothing from the secret. connection_string is marked sensitive, and no local, no other input and no output reads it. That is why none of the 29 outputs is sensitive and why no nonsensitive() call appears anywhere in the module.
| Concern | This module's default | Opt-out | Why |
|---|---|---|---|
| Hub-to-storage auth | keyBased (the provider's) |
set identityBased |
Flip declined, deliberately. identityBased fails unless the hub has an identity holding Storage Blob Data Contributor before the first apply β defaulting to it would make the empty call fail, not make it safe |
| Upload notifications | notifications_enabled = false |
set true |
A delivery feature, not a security control |
| SAS URI lifetime | sas_ttl = "PT1H" |
shorten it | The provider's default; shortening is the real hardening step here |
| Secret emission | nothing derived from the secret is emitted | β | Re-emitting copies the key into every consuming state |
| Secret marking | sensitive = true on the input |
β | Redacts plan output; does not encrypt state |
π΄ The empty call is not the safe call, and this module says so rather than implying otherwise. The provider makes a credential-bearing argument required, so there is no configuration of this resource without a storage key in state. Where the secure-by-default rule cannot apply, this suite states it plainly and compensates: the value set is validated, the probable mistake is rejected by name, and the residual exposure is emitted as
plan_access_is_credential_access.
terraform init -backend=false
terraform validate
terraform fmt -checkPin ?ref=v1.0.0, never a branch. Plan-only β a human applies from CI.
terraform validate proves the configuration parses and the types line up. terraform fmt -check proves formatting. Neither fires a root-module variable validation β terraform console with a .tfvars file does, and that is how all 14 validations here were exercised, each confirmed by the line number it reported.
What only a real plan against Azure can exercise: that the hub exists, that the storage container exists, that the identity holds Storage Blob Data Contributor, that the storage account shares the hub's subscription, and whether the first-apply guard trips.
id = "/subscriptions/.../resourceGroups/rg-iot-eastus/providers/Microsoft.Devices/IotHubs/iot-plant-eastus"
iothub_name = "iot-plant-eastus"
container_name = "device-uploads"
authentication_type = "identityBased"
uses_identity_based_authentication = true
uses_the_hubs_system_assigned_identity = false
notifications_enabled = true
sas_ttl = "PT15M"
notification_settings_are_inert = false
notification_settings_configured_but_inert = false
destroy_leaves_the_configuration_live_on_the_hub = true
a_rotated_storage_key_produces_no_plan_diff = true
plan_access_is_credential_access = true
| Symptom | Cause | Fix |
|---|---|---|
identity_id can only be specified when authentication_type is identityBased |
Set identity_id with key-based auth |
This module rejects it at parse time; set authentication_type = "identityBased" or drop identity_id |
| Plan never converges, alternating diff | Two module blocks against one hub β same Resource ID | Use exactly one per hub |
| Spurious changes on every plan | Both this module and the hub's inline file_upload are in use |
Choose one model; set file_upload = null on the hub module |
| Rotated the storage key, plan shows no changes | Azure masks the key; the provider excludes it from the diff | Expected. Force a re-apply out of band; rotation is not drift Terraform can see |
terraform destroy succeeded but uploads still work |
The delete path writes the hub back unmodified | Expected. Clear file upload on the hub itself |
| First apply fails on storage permissions, succeeds on retry | Role assignment had not propagated | Allow a few minutes after assigning Storage Blob Data Contributor |
must be imported into the state |
The hub already has a complete file-upload configuration | Import using the hub's Resource ID |
| A hub with a partial configuration was silently adopted | The guard only trips when both connection string and container are already set | Check the hub before the first apply |
container_name must be 3-63 characters... |
Container names are lowercase-only | Azure Storage naming rules, not this module's |
- Provider resource:
azurerm_iothub_file_upload - Configure IoT Hub file uploads
- IoT Hub support for managed identities
- Sibling modules:
terraform-azurerm-iothub,terraform-azurerm-iothub-route,terraform-azurerm-iothub-fallback-route,terraform-azurerm-storage-account,terraform-azurerm-user-assigned-identity,terraform-azurerm-role-assignments - This module's
SCOPE.md
π "Infrastructure as Code should be standardized, consistent, and secure."