The single deeply-typed boundary for an Azure Data Explorer estate β the cluster, its databases, follower attachments, RBAC, managed private endpoints, KQL setup scripts, and every ingestion data connection (Cosmos DB Β· Event Grid Β· Event Hub Β· IoT Hub). Built for AzureRM v4.x.
This composite module provisions an Azure Data Explorer (Kusto) cluster and every tightly-coupled child resource that has no independent lifecycle, behind one module boundary:
- π£ One cluster (
this) β SKU, managed identity, optimized auto-scale, language extensions, network posture. - ποΈ Databases β
for_eachmap, with hot-cache and soft-delete retention. - π Attached (follower) database configurations β cross-cluster data sharing.
- π Customer-managed key (CMK) β optional Key Vault / Managed HSM encryption (platform-managed by default).
- π€ Cluster- and database-level principal assignments β Entra ID RBAC.
- π‘οΈ Managed private endpoints β outbound private-link to Storage / Event Hub / IoT Hub / SQL / ADX.
- π KQL scripts β schema/table/mapping bootstrap (inline or blob-backed; marked sensitive).
- π₯ Data connections β Cosmos DB, Event Grid, Event Hub, and IoT Hub ingestion.
- π Read-only lookups β
data.azurerm_kusto_cluster/data.azurerm_kusto_databasefor resolving leader/sibling IDs.
π‘ Why it matters: one
terraform applystands up a query-ready Data Explorer estate with secure-by-default networking and encryption β and because every collection is afor_eachmap, adding a database or data connection never re-plans the cluster.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
flowchart LR
RG["tf-mod-azure-resource-group"]
MI["tf-mod-azure-managed-identity"]
KV["tf-mod-azure-key-vault"]
ADX["tf-mod-azure-data-explorer<br/>(this module)"]
PE["tf-mod-azure-private-endpoint"]
RA["tf-mod-azure-role-assignment"]
DIAG["tf-mod-azure-monitor-diagnostic-setting"]
SRC["tf-mod-azure-event-hub Β· tf-mod-azure-iot-hub<br/>tf-mod-azure-storage-account Β· tf-mod-azure-cosmosdb"]
RG -->|"resource_group_name"| ADX
MI -->|"identity_ids Β· user_identity"| ADX
KV -->|"key_vault_id Β· key_name"| ADX
SRC -->|"eventhub_id Β· iothub_id Β· storage_account_id Β· cosmosdb_container_id"| ADX
ADX -->|"id"| PE
ADX -->|"identity_principal_id"| RA
ADX -->|"id"| DIAG
style ADX fill:#8957E5,color:#fff
style RG fill:#0078D4,color:#fff
This module consumes resource_group_name, managed-identity IDs, Key Vault key references, and ingestion-source resource IDs from its siblings, and emits id, identity_principal_id, and per-collection ID maps for tf-mod-azure-private-endpoint, tf-mod-azure-role-assignment, and diagnostic settings β see the Cross-Module Contract.
flowchart TD
CLUSTER["azurerm_kusto_cluster<br/>(this)"]
CMK["customer_managed_key<br/>(0-or-1)"]
DB["database<br/>(for_each)"]
ADC["attached_database_configuration<br/>(for_each)"]
CPA["cluster_principal_assignment<br/>(for_each)"]
MPE["managed_private_endpoint<br/>(for_each)"]
DPA["database_principal_assignment<br/>(for_each)"]
SCRIPT["script<br/>(for_each Β· sensitive)"]
COSMOS["cosmosdb_data_connection<br/>(for_each)"]
EG["eventgrid_data_connection<br/>(for_each)"]
EH["eventhub_data_connection<br/>(for_each)"]
IOT["iothub_data_connection<br/>(for_each)"]
CLUSTER --> CMK
CLUSTER --> DB
CLUSTER --> ADC
CLUSTER --> CPA
CLUSTER --> MPE
DB --> DPA
DB --> SCRIPT
DB --> COSMOS
DB --> EG
DB --> EH
DB --> IOT
style CLUSTER fill:#8957E5,color:#fff
Resource inventory (12 managed resource types + 2 data sources):
- π£
azurerm_kusto_cluster.thisβ primary: the Data Explorer cluster. - π
azurerm_kusto_cluster_customer_managed_key.customer_managed_keyβ optional CMK encryption (0-or-1). - ποΈ
azurerm_kusto_database.thisβ databases. - π
azurerm_kusto_attached_database_configuration.attached_database_configurationβ follower/data-sharing attachments. - π€
azurerm_kusto_cluster_principal_assignment.cluster_principal_assignmentβ cluster-scope RBAC. - π€
azurerm_kusto_database_principal_assignment.database_principal_assignmentβ database-scope RBAC. - π‘οΈ
azurerm_kusto_cluster_managed_private_endpoint.managed_private_endpointβ outbound managed private endpoints. - π
azurerm_kusto_script.scriptβ KQL setup scripts. - π₯
azurerm_kusto_cosmosdb_data_connection.cosmosdb_data_connectionβ Cosmos DB ingestion. - π₯
azurerm_kusto_eventgrid_data_connection.eventgrid_data_connectionβ Event Grid (blob event) ingestion. - π₯
azurerm_kusto_eventhub_data_connection.eventhub_data_connectionβ Event Hub ingestion. - π₯
azurerm_kusto_iothub_data_connection.iothub_data_connectionβ IoT Hub ingestion. - π
data.azurerm_kusto_cluster.lookup/data.azurerm_kusto_database.lookupβ read-only lookups.
- Terraform
>= 1.12.0 - AzureRM
>= 4.0, < 5.0 - The module declares the provider requirement only β it configures no
provider {}block, so it composes cleanly under any root.
βΉοΈ Schema notes that bite (azurerm 4.x):
- The cluster
skuis a required block;capacityis required unlessoptimized_auto_scaleis set.azurerm_kusto_cluster_principal_assignmentandazurerm_kusto_database_principal_assignmentexpose noupdatetimeout β every field forces replacement.- The provider exposes both a
language_extension(current) and a deprecatedlanguage_extensionsblock; this module renders only the currentlanguage_extension.azurerm_kusto_cosmosdb_data_connectionandazurerm_kusto_iothub_data_connectionare fully immutable β any change forces a new connection.
tf-mod-azure-data-explorer/
βββ providers.tf # Terraform & azurerm version pins (no provider block)
βββ variables.tf # Deeply-typed inputs, validation, secure defaults
βββ main.tf # Total renderer: cluster + for_each child collections
βββ outputs.tf # id, identity_principal_id, per-collection ID maps
βββ SCOPE.md # Cross-module contract (consumes / emits / gotchas)
βββ README.md # This file
module "data_explorer" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-data-explorer?ref=v1.0.0"
name = "adxcaseydev01"
resource_group_name = module.resource_group.name
location = "eastus2"
sku = {
name = "Dev(No SLA)_Standard_D11_v2"
capacity = 1
}
databases = {
telemetry = {
name = "telemetry"
hot_cache_period = "P7D"
}
}
tags = { environment = "dev", workload = "analytics" }
}π With no networking inputs supplied, the cluster is created with
public_network_access_enabled = falseanddisk_encryption_enabled = trueβ the secure default.
| Input | Type | Source |
|---|---|---|
resource_group_name |
string |
tf-mod-azure-resource-group.name |
location |
string |
tf-mod-azure-resource-group.location (or root) |
identity.identity_ids |
list(string) |
tf-mod-azure-managed-identity.id |
customer_managed_key.key_vault_id / key_name |
string |
tf-mod-azure-key-vault.id / Key Vault key name |
customer_managed_key.user_identity |
string |
tf-mod-azure-managed-identity.id |
managed_private_endpoints[].private_link_resource_id |
string |
tf-mod-azure-storage-account / tf-mod-azure-event-hub / tf-mod-azure-iot-hub .id |
eventhub_data_connections[].eventhub_id |
string |
tf-mod-azure-event-hub.id |
eventgrid_data_connections[].storage_account_id / eventhub_id |
string |
tf-mod-azure-storage-account.id / tf-mod-azure-event-hub.id |
iothub_data_connections[].iothub_id |
string |
tf-mod-azure-iot-hub.id |
cosmosdb_data_connections[].cosmosdb_container_id / managed_identity_id |
string |
tf-mod-azure-cosmosdb.container_id / tf-mod-azure-managed-identity.id |
| Output | Description | Consumed by |
|---|---|---|
id |
Kusto Cluster resource ID | tf-mod-azure-private-endpoint (resource_id), tf-mod-azure-role-assignment (scope), tf-mod-azure-monitor-diagnostic-setting |
name |
Cluster name | Any module referencing the cluster by name |
resource_group_name |
Resource group (passthrough) | Sibling modules in the same RG |
location |
Cluster region | Sibling modules that colocate resources with the cluster |
uri / data_ingestion_uri |
Query / ingestion FQDNs | KQL clients, ingestion pipelines |
identity_principal_id |
System-identity object ID (null if none) |
tf-mod-azure-role-assignment (principal_id), Key Vault access policies |
identity_tenant_id |
System-identity tenant ID (null if none) |
Cross-tenant trust configuration |
database_ids / database_names |
Map of database key β ID / name | Data-connection wiring, downstream RBAC |
*_data_connection_ids, *_principal_assignment_ids, managed_private_endpoint_ids, script_ids, attached_database_configuration_ids |
Per-collection ID maps | Monitoring, dependency wiring |
cluster_lookup_ids / cluster_lookup_uris / database_lookup_ids |
Resolved IDs/URIs of looked-up existing resources | Follower attachments, cross-cluster queries |
1οΈβ£ Minimal β smallest call that produces a real cluster
module "data_explorer" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-data-explorer?ref=v1.0.0"
name = "adxcaseydev01"
resource_group_name = "rg-analytics-dev"
location = "eastus2"
sku = {
name = "Dev(No SLA)_Standard_D11_v2"
capacity = 1
}
}2οΈβ£ Production SKU with optimized auto-scale
module "data_explorer" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-data-explorer?ref=v1.0.0"
name = "adxcaseyprod01"
resource_group_name = "rg-analytics-prod"
location = "eastus2"
sku = {
name = "Standard_E8ads_v5" # capacity omitted β set by auto-scale
}
optimized_auto_scale = {
minimum_instances = 2
maximum_instances = 10
}
zones = ["1", "2", "3"] # availability zones (immutable; not on Dev SKUs)
}
β οΈ zonesis immutable β changing it forces a new cluster. Set it at creation time.
3οΈβ£ Databases with retention
databases = {
telemetry = {
name = "telemetry"
hot_cache_period = "P31D" # 31 days hot cache
soft_delete_period = "P365D"
}
audit = {
name = "audit"
hot_cache_period = "P7D"
soft_delete_period = "P2555D" # ~7 years
}
}4οΈβ£ System-assigned managed identity
identity = {
type = "SystemAssigned"
}The system identity's object ID is exported as identity_principal_id for downstream role assignments (e.g. granting the cluster read access to a Cosmos DB account).
5οΈβ£ User-assigned managed identity
identity = {
type = "SystemAssigned, UserAssigned"
identity_ids = [module.managed_identity.id]
}π‘
identity_idsis required whenevertypeincludesUserAssignedβ the module enforces this with avalidationblock.
6οΈβ£ Cluster & database RBAC (principal assignments)
cluster_principal_assignments = {
platform_admin = {
name = "platform-admin"
principal_id = "00000000-0000-0000-0000-000000000000"
principal_type = "Group"
role = "AllDatabasesViewer"
tenant_id = "11111111-1111-1111-1111-111111111111"
}
}
database_principal_assignments = {
telemetry_ingestor = {
name = "telemetry-ingestor"
database_name = "telemetry" # matches databases["telemetry"].name
principal_id = "22222222-2222-2222-2222-222222222222"
principal_type = "App"
role = "Ingestor"
tenant_id = "11111111-1111-1111-1111-111111111111"
}
}βΉοΈ Cluster roles:
AllDatabasesAdminΒ·AllDatabasesViewerΒ·AllDatabasesMonitor. Database roles:AdminΒ·IngestorΒ·MonitorΒ·UnrestrictedViewerΒ·UserΒ·Viewer.
7οΈβ£ KQL setup scripts (inline & blob-backed)
scripts = {
create_table = {
name = "create-telemetry-table"
database_key = "telemetry" # resolves to databases["telemetry"].id
script_content = <<-KQL
.create table Events (Timestamp:datetime, DeviceId:string, Payload:dynamic)
KQL
}
blob_script = {
name = "bootstrap-from-blob"
database_id = "/subscriptions/.../databases/telemetry"
url = "https://sa.blob.core.windows.net/scripts/bootstrap.kql"
sas_token = var.script_sas_token
}
}π The
scriptsvariable is markedsensitive = true(it can carryscript_contentandsas_token). The module iterates it withfor_each = toset(nonsensitive(keys(var.scripts)))so keys can drive resources without leaking secret values. π‘ Provide exactly one ofdatabase_key(a key fromdatabases) ordatabase_id(an explicit ID).
8οΈβ£ Event Hub data connection
eventhub_data_connections = {
telemetry_stream = {
name = "telemetry-stream"
database_name = "telemetry"
eventhub_id = module.event_hub.id
consumer_group = "adx"
data_format = "JSON"
table_name = "Events"
mapping_rule_name = "EventsMapping"
compression = "GZip"
identity_id = module.managed_identity.id # trusted-service auth (recommended)
}
}9οΈβ£ Event Grid & IoT Hub data connections
eventgrid_data_connections = {
blob_drops = {
name = "blob-drops"
database_name = "telemetry"
storage_account_id = module.storage_account.id
eventhub_id = module.event_hub.id
eventhub_consumer_group_name = "adx-eg"
blob_storage_event_type = "Microsoft.Storage.BlobCreated"
data_format = "JSON"
}
}
iothub_data_connections = {
devices = {
name = "device-telemetry"
database_name = "telemetry"
iothub_id = module.iot_hub.id
consumer_group = "adx"
shared_access_policy_name = "adx-read"
data_format = "JSON"
}
}π Cosmos DB data connection
identity = { type = "SystemAssigned" }
cosmosdb_data_connections = {
orders = {
name = "orders-cdc"
database_key = "telemetry"
cosmosdb_container_id = module.cosmosdb.container_id
managed_identity_id = module.data_explorer.id # cluster ID for system identity
table_name = "Orders"
mapping_rule_name = "OrdersMapping"
}
}
β οΈ Cosmos DB connections are fully immutable β every field forces a new connection. The cluster's identity needs the Cosmos DB Built-in Data Reader role on the account.
1οΈβ£1οΈβ£ Attached (follower) database configuration β cross-cluster sharing
# Look up the leader cluster by nameβ¦
cluster_lookups = {
leader = { name = "adxleaderprod", resource_group_name = "rg-leader" }
}
# β¦then follow one of its databases.
attached_database_configurations = {
shared = {
name = "follow-sales"
cluster_id = module.data_explorer.cluster_lookup_ids["leader"]
database_name = "sales"
sharing = {
tables_to_include = ["FactSales"]
functions_to_exclude = ["InternalOnly"]
}
}
}
β οΈ A leader cluster supports at most 100 follower clusters. If streaming ingestion is enabled on the leader, it must also be enabled on this follower to receive streamed data.
1οΈβ£2οΈβ£ for_each at scale β many databases from a map(object)
locals {
tenants = ["acme", "globex", "initech", "umbrella"]
}
module "data_explorer" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-data-explorer?ref=v1.0.0"
name = "adxcaseyprod01"
resource_group_name = "rg-analytics-prod"
location = "eastus2"
sku = { name = "Standard_E8ads_v5", capacity = 2 }
databases = {
for t in local.tenants : t => {
name = "db-${t}"
hot_cache_period = "P14D"
}
}
}π‘ The map key (
acme,globex, β¦) is the resource's stable identity. Choose keys that never change β renaming a key destroys and recreates that database. See Architecture Notes.
1οΈβ£3οΈβ£ Private endpoint wiring β feed `id` into tf-mod-azure-private-endpoint
module "data_explorer" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-data-explorer?ref=v1.0.0"
name = "adxcaseyprod01"
resource_group_name = "rg-analytics-prod"
location = "eastus2"
sku = { name = "Standard_E8ads_v5", capacity = 2 }
public_network_access_enabled = false # inbound only via private endpoint
}
module "adx_private_endpoint" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-private-endpoint?ref=v1.0.0"
name = "pe-adxcaseyprod01"
resource_group_name = "rg-analytics-prod"
location = "eastus2"
subnet_id = module.subnet.id
private_connection_resource_id = module.data_explorer.id # β the keystone wire
subresource_names = ["cluster"]
}
β οΈ Private endpoints are not supported on VNet-injected clusters β do not combinevirtual_network_configurationwith an inbound private endpoint.
1οΈβ£4οΈβ£ CMK encryption with Key Vault
identity = { type = "SystemAssigned" } # required for CMK
customer_managed_key = {
key_vault_id = module.key_vault.id
key_name = "adx-cmk"
# key_version omitted β Azure auto-rotates to the latest version
}π
null(the default) = platform-managed key. Exactly one ofkey_vault_idormanaged_hsm_key_idmust be set. Switching to/from CMK or rotating the key reference is an in-place update β it does not recreate the cluster. The cluster identity needsGet/WrapKey/UnwrapKeyon the key.
1οΈβ£5οΈβ£ Security / hardening β locked-down, -compliant variant
module "data_explorer" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-data-explorer?ref=v1.0.0"
name = "adxcaseyprod01"
resource_group_name = "rg-analytics-prod"
location = "eastus2"
sku = { name = "Standard_E8ads_v5", capacity = 3 }
zones = ["1", "2", "3"]
# Network β inbound private only, egress locked down
public_network_access_enabled = false
outbound_network_access_restricted = true
allowed_fqdns = ["mystorage.blob.core.windows.net"]
trusted_external_tenants = [] # this tenant only
# Encryption
disk_encryption_enabled = true
double_encryption_enabled = true # immutable β set at creation
identity = { type = "SystemAssigned" }
customer_managed_key = {
key_vault_id = module.key_vault.id
key_name = "adx-cmk"
}
# Egress via managed private endpoint (no public storage exposure)
managed_private_endpoints = {
ingest_storage = {
name = "mpe-ingest-sa"
group_id = "blob"
private_link_resource_id = module.storage_account.id
}
}
}π
double_encryption_enabledis immutable β it can only be set at create time.
ποΈ End-to-end composition (mandatory) β full suite wired via outputs β inputs
module "resource_group" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-resource-group?ref=v1.0.0"
name = "rg-analytics-prod"
location = "eastus2"
}
module "managed_identity" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-managed-identity?ref=v1.0.0"
name = "id-adx-prod"
resource_group_name = module.resource_group.name
location = module.resource_group.location
}
module "key_vault" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-key-vault?ref=v1.0.0"
name = "kv-adx-prod"
resource_group_name = module.resource_group.name
location = module.resource_group.location
}
module "event_hub" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-event-hub?ref=v1.0.0"
name = "evh-telemetry"
resource_group_name = module.resource_group.name
location = module.resource_group.location
}
module "data_explorer" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-data-explorer?ref=v1.0.0"
name = "adxcaseyprod01"
resource_group_name = module.resource_group.name
location = module.resource_group.location
sku = { name = "Standard_E8ads_v5", capacity = 3 }
zones = ["1", "2", "3"]
identity = {
type = "SystemAssigned, UserAssigned"
identity_ids = [module.managed_identity.id]
}
public_network_access_enabled = false
disk_encryption_enabled = true
customer_managed_key = {
key_vault_id = module.key_vault.id
key_name = "adx-cmk"
user_identity = module.managed_identity.id
}
databases = {
telemetry = { name = "telemetry", hot_cache_period = "P31D" }
}
eventhub_data_connections = {
telemetry_stream = {
name = "telemetry-stream"
database_name = "telemetry"
eventhub_id = module.event_hub.id
consumer_group = "adx"
data_format = "JSON"
table_name = "Events"
identity_id = module.managed_identity.id
}
}
}
module "adx_private_endpoint" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-private-endpoint?ref=v1.0.0"
name = "pe-adxcaseyprod01"
resource_group_name = module.resource_group.name
location = module.resource_group.location
subnet_id = module.subnet.id
private_connection_resource_id = module.data_explorer.id
subresource_names = ["cluster"]
}
module "adx_reader_role" {
source = "git::https://github.com/microsoftexpert/tf-mod-azure-role-assignment?ref=v1.0.0"
scope = module.data_explorer.id
principal_id = module.data_explorer.identity_principal_id
role_definition_name = "Reader"
}Core (required): name, resource_group_name, location, sku.
Cluster config (optional): identity, public_network_access_enabled, disk_encryption_enabled, double_encryption_enabled, streaming_ingestion_enabled, purge_enabled, auto_stop_enabled, outbound_network_access_restricted, public_ip_type, allowed_fqdns, allowed_ip_ranges, trusted_external_tenants, zones, optimized_auto_scale, virtual_network_configuration, language_extensions, customer_managed_key.
Child collections (maps, default {}): databases, attached_database_configurations, cluster_principal_assignments, database_principal_assignments, managed_private_endpoints, scripts (sensitive), cosmosdb_data_connections, eventgrid_data_connections, eventhub_data_connections, iothub_data_connections.
Read-only lookups: cluster_lookups, database_lookups.
Universal tail: tags, timeouts.
Full nested object schemas
sku = object({
name = string # closed set β see validation in variables.tf
capacity = optional(number) # required unless optimized_auto_scale is set
})
identity = object({
type = string # SystemAssigned | UserAssigned | "SystemAssigned, UserAssigned"
identity_ids = optional(list(string)) # required when type includes UserAssigned
})
optimized_auto_scale = object({
minimum_instances = number # 0-1000
maximum_instances = number # 0-1000, >= minimum
})
customer_managed_key = object({ # null = platform-managed (default)
key_vault_id = optional(string) # XOR managed_hsm_key_id
key_name = optional(string)
key_version = optional(string) # omit for auto-rotation
managed_hsm_key_id = optional(string)
user_identity = optional(string)
})
databases = map(object({
name = string
location = optional(string) # defaults to cluster location
resource_group_name = optional(string) # defaults to cluster RG
hot_cache_period = optional(string) # ISO 8601, e.g. "P7D"
soft_delete_period = optional(string)
}))
scripts = map(object({ # SENSITIVE
name = string
database_id = optional(string) # XOR database_key
database_key = optional(string)
script_content = optional(string) # XOR url/sas_token
sas_token = optional(string)
url = optional(string)
continue_on_errors_enabled = optional(bool, false)
force_an_update_when_value_changed = optional(string)
script_level = optional(string, "Database") # Database | Cluster
principal_permissions_action = optional(string)
}))
eventhub_data_connections = map(object({
name = string
database_name = string
eventhub_id = string
consumer_group = string
location = optional(string)
resource_group_name = optional(string)
compression = optional(string, "None") # GZip | None
data_format = optional(string) # APACHEAVRO | AVRO | CSV | JSON | β¦
database_routing_type = optional(string, "Single") # Single | Multi
event_system_properties = optional(list(string))
identity_id = optional(string)
mapping_rule_name = optional(string)
table_name = optional(string)
retrieval_start_date = optional(string)
}))βΉοΈ See
variables.tffor every collection's complete schema, defaults, andvalidationrules.
| Output | Description |
|---|---|
id |
Kusto Cluster resource ID (feeds private endpoints, role assignments, diagnostics) |
name |
Cluster name |
resource_group_name |
Resource group (passthrough) |
location |
Cluster region |
uri |
Cluster query URI (FQDN) |
data_ingestion_uri |
Cluster data-ingestion URI |
identity_principal_id |
System-identity object ID β null when no system identity |
identity_tenant_id |
System-identity tenant ID β null when no system identity |
database_ids / database_names |
Map of database key β ID / name |
attached_database_configuration_ids |
Map of key β attached config ID |
cluster_principal_assignment_ids |
Map of key β cluster RBAC assignment ID |
database_principal_assignment_ids |
Map of key β database RBAC assignment ID |
managed_private_endpoint_ids |
Map of key β managed private endpoint ID |
script_ids |
Map of key β script ID |
cosmosdb_data_connection_ids |
Map of key β Cosmos DB connection ID |
eventgrid_data_connection_ids |
Map of key β Event Grid connection ID |
eventhub_data_connection_ids |
Map of key β Event Hub connection ID |
iothub_data_connection_ids |
Map of key β IoT Hub connection ID |
cluster_lookup_ids / cluster_lookup_uris |
Map of lookup key β resolved cluster ID / URI |
database_lookup_ids |
Map of lookup key β resolved database ID |
βΉοΈ No output is marked
sensitiveβ IDs and URIs are non-secret. Secret-bearing inputs (scripts) are sensitive; the module never emits script content.
-
Cluster β database β table hierarchy. Per Microsoft's logical architecture, the cluster is the top-level compute/storage container; databases live inside it, tables inside databases. This module owns the cluster and database layers; tables/mappings are bootstrapped via
scripts. -
Private endpoint integration. Set
public_network_access_enabled = falseand wiremodule.data_explorer.idintotf-mod-azure-private-endpoint(subresource_names = ["cluster"]). The cluster firewall governs only the public endpoint; private endpoints use a consent flow.β οΈ Private endpoints are not supported on VNet-injected clusters βvirtual_network_configurationand inbound private endpoints are mutually exclusive. For outbound private connectivity to ingestion sources, usemanaged_private_endpoints(Microsoft recommends managed-identity + trusted-service access over MPE for Storage/Event Hubs, except the Event Grid flow). -
for_eachmap(object) lifecycle. Every child collection is keyed by a caller-supplied string. The key is the resource's identity β renaming a key (e.g.telemetryβtelemetry_v2) makes Terraform plan a destroy + recreate of that one element, while the rest of the map is untouched. Choose keys that outlive the values they describe. Because collections are independent maps, you can safely target a single element during incident response:terraform apply -target='module.data_explorer.azurerm_kusto_database.this["telemetry"]'. Partial applies never force a cluster re-plan. -
Secrets in
for_eachkeys.scriptsissensitive = true(it can carryscript_content/sas_token). Terraform forbids a sensitive value infor_each, so the module usesfor_each = toset(nonsensitive(keys(var.scripts)))β only the keys are de-sensitised for iteration; the secret values stay sensitive and are read back per-key. -
SKU gating.
Dev(No SLA)_*SKUs are single-node and do not support availabilityzonesoroptimized_auto_scale(and have no SLA). Production engine SKUs (*_v4/*_v5,EC*-series) are required for those, and for the largest node counts. Scaling SKU/capacity is an in-place update; changingzonesordouble_encryption_enabledis immutable (recreate). The module validates the SKU name against the full provider-supported set. -
CMK vs platform-managed key.
customer_managed_key = null(default) uses Microsoft-managed encryption. Supplying the object switches to CMK β in place, no recreate β and rotating the key reference is also in place. Omitkey_versionto let Azure auto-rotate. CMK requires a cluster managed identity withGet/WrapKey/UnwrapKeyon the key; exactly one ofkey_vault_idormanaged_hsm_key_idis allowed (enforced by validation). -
Capacity limits to design around (per-subscription / per-cluster): 20 clusters per region per subscription, 1,000 instances per cluster, 10,000 databases per cluster, 100 follower clusters per leader, and a control-plane write budget of ~1,000 ops/hour β large
for_eachmaps applied in one run can approach the write budget.
- π Secure by default β
public_network_access_enabled = false,disk_encryption_enabled = true,purge_enabled = false, own-tenant trust. - π§± Type is the contract β deeply-typed
objectschemas,optionalwith baked-in defaults,validation {}on every enum and closed set; malformed input fails at plan time. - π§© One primary
this, children by role β composite boundary withfor_eachmaps, nevercount. - π
main.tfis a total renderer βdynamicblocks for every optional/repeating block,try(x, null)on every optional nested field. - π€ Consumes-not-creates β ingestion sources, identities, and Key Vault keys arrive by ID; the module never creates them.
- π Secrets stay sensitive β secret-bearing inputs are
sensitive = true; no secret is emitted as an output.
terraform init -backend=false
terraform validate
terraform fmt -check
terraform plan -out tfplan
terraform apply tfplan
terraform output
β οΈ Always pin the module by tag β?ref=v1.0.0, never a branch. Tags are immutable; a branch can change under you and silently alter a plan.
The offline proof gate (no Azure credentials required):
terraform fmt -check # zero formatting drift
terraform validate # schema-valid configuration
tflint # lint for provider-specific issuesThis module passes terraform validate and terraform fmt -check clean.
id = "/subscriptions/.../providers/Microsoft.Kusto/clusters/adxcaseyprod01"
name = "adxcaseyprod01"
uri = "https://adxcaseyprod01.eastus2.kusto.windows.net"
data_ingestion_uri = "https://ingest-adxcaseyprod01.eastus2.kusto.windows.net"
identity_principal_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
database_ids = {
"telemetry" = "/subscriptions/.../clusters/adxcaseyprod01/databases/telemetry"
}
eventhub_data_connection_ids = {
"telemetry_stream" = "/subscriptions/.../dataConnections/telemetry-stream"
}
-
sku.capacityrequired error. The provider requirescapacityunlessoptimized_auto_scaleis set. Supply one or the other. -
zones/double_encryption_enabledwon't change. Both are immutable β Terraform will plan a full cluster replacement. Set them at creation; to change them on an existing cluster, plan a deliberate recreate (and migrate data first). -
Availability zones / auto-scale rejected on a Dev SKU.
Dev(No SLA)_*SKUs don't support zones or optimized auto-scale. Move to a production engine SKU (*_v4/*_v5). -
Private endpoint creation fails on a VNet-injected cluster. Private endpoints are not supported for clusters that use
virtual_network_configuration. Choose one connectivity model;virtual_network_configurationis also a deprecated pattern β prefer private endpoints + managed private endpoints. -
Public access still open after apply. Setting
public_network_access_enabled = falseblocks the public endpoint, but inbound private access requires a separatetf-mod-azure-private-endpoint. The cluster firewall only governs the public endpoint. -
Data connection applies before the table exists. Event Hub/Grid/IoT/Cosmos connections require the target table (and mapping rule) to already exist. Bootstrap them with a
scriptfirst β the module already orders data connectionsdepends_onthe databases, but it cannot order them after table creation inside a script; sequence table-creating scripts ahead of connections. -
Streamed data not reaching a follower. If streaming ingestion is enabled on the leader, it must also be enabled on this follower cluster (
streaming_ingestion_enabled = true) to receive streamed extents. -
AllowedFqdnListblocks ingestion. Whenoutbound_network_access_restricted = true, you must list every egress FQDN inallowed_fqdns(e.g. your storage account blob endpoint) or ingestion/callouts fail silently. -
Cosmos DB / IoT Hub connection re-creates on every plan diff. These resource types are fully immutable β any attribute change forces replacement. Treat their inputs as set-once.
-
Forbiddenwriting CMK key. The cluster identity lacks Key Vault permissions. Grant the system (oruser_identity)Get/WrapKey/UnwrapKeyon the key before applyingcustomer_managed_key. -
Throttling / "aborted due to throttling" on small SKUs. Dev/Extra-small SKUs throttle under concurrent query/management load. Scale to the next SKU. Note the control-plane budget of ~1,000 write ops/hour when applying very large
for_eachmaps in a single run. -
language_extensionsshows disk pressure when idle. The language-extension sandbox reserves disk even with no query running β size the SKU accordingly when enablingPYTHON/R.
- Terraform Registry β
azurerm_kusto_clusterand theazurerm_kusto_*resource family - Microsoft Learn β What is Azure Data Explorer? and How Azure Data Explorer works
- Microsoft Learn β Network security for Azure Data Explorer (private endpoints, managed private endpoints, public access)
- Microsoft Learn β Reliability in Azure Data Explorer (availability zones, storage redundancy)
- Microsoft Learn β Azure subscription and service limits (Azure Data Explorer limits)
- Microsoft Learn β Customer-managed keys for Azure Data Explorer
π "Infrastructure as Code should be standardized, consistent, and secure."