Quick take — Provision Azure Digital Twins with Terraform and azurerm ~> 4.0: a var-driven module wiring the instance, system-assigned identity, RBAC data-owner role, and a private endpoint for production IoT spatial graphs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "digital_twins" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-digital-twins?ref=v1.0.0"
name = "..." # Name of the ADT instance; 3-63 chars, alphanumeric/hyph…
resource_group_name = "..." # Resource group to create the instance in.
location = "..." # Azure region; ADT is region-limited, confirm availabili…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Digital Twins (ADT) is a PaaS service that lets you build a live, queryable graph of a physical environment — buildings, factory lines, energy grids, fleets — modelled in DTDL (Digital Twins Definition Language). Telemetry from IoT devices flows in, twins are updated, and the relationships between them (a floor contains rooms, a room contains sensors) become a queryable spatial graph you can reason over. The control-plane resource that hosts all of this is a single azurerm_digital_twins_instance, but a bare instance is rarely production-ready on its own.
This module wraps azurerm_digital_twins_instance so that every ADT deployment in your estate is consistent: it enables a system-assigned managed identity (so the instance can authenticate to event routing endpoints like Event Hub / Event Grid without secrets), assigns the Azure Digital Twins Data Owner RBAC role to the principals that need data-plane access (ADT data-plane operations are governed entirely by Azure RBAC, not keys), and optionally locks the instance down behind a Private Endpoint with public network access disabled. Wrapping these together means you stop hand-rolling the same three or four resources per project and get a single versioned input contract instead.
When to use it
- You are standing up an IoT spatial-graph backend — smart buildings, manufacturing OT, energy/utilities — and need a governed ADT instance per environment (dev/test/prod).
- You want data-plane access controlled by RBAC, not connection strings — ADT has no account keys, so role assignments are mandatory and best codified in IaC.
- You need the instance reachable only over a private endpoint on a VNet, with
public_network_access_enabled = false, to satisfy a network-isolation baseline. - You are deploying ADT repeatedly across teams/subscriptions and want a single source of truth for identity, tagging, and RBAC.
Skip it if you only ever click one instance into a sandbox by hand, or if your scenario is pure device-to-cloud ingestion with IoT Hub and no spatial graph — in that case you do not need Digital Twins at all.
Module structure
terraform-module-azure-digital-twins/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
# main.tf
# Control-plane instance. ADT has no account keys — data-plane auth is RBAC only,
# so a managed identity is enabled here for outbound auth to event-routing endpoints.
resource "azurerm_digital_twins_instance" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
public_network_access_enabled = var.public_network_access_enabled
identity {
type = "SystemAssigned"
}
tags = var.tags
}
# Data-plane RBAC. ADT operations (twins, models, queries, event routes) are
# authorized exclusively through Azure RBAC. "Azure Digital Twins Data Owner"
# grants full data-plane control to the listed principals (e.g. an app's identity
# or a deployment service principal).
resource "azurerm_role_assignment" "data_owner" {
for_each = toset(var.data_owner_principal_ids)
scope = azurerm_digital_twins_instance.this.id
role_definition_name = "Azure Digital Twins Data Owner"
principal_id = each.value
}
# Optional private endpoint. When enabled, pair with
# public_network_access_enabled = false to fully isolate the instance on a VNet.
# The "API" subresource is the data-plane endpoint for Digital Twins.
resource "azurerm_private_endpoint" "this" {
count = var.private_endpoint != null ? 1 : 0
name = coalesce(var.private_endpoint.name, "${var.name}-pe")
resource_group_name = var.resource_group_name
location = var.location
subnet_id = var.private_endpoint.subnet_id
private_service_connection {
name = "${var.name}-psc"
private_connection_resource_id = azurerm_digital_twins_instance.this.id
subresource_names = ["API"]
is_manual_connection = false
}
dynamic "private_dns_zone_group" {
for_each = var.private_endpoint.private_dns_zone_ids != null ? [1] : []
content {
name = "default"
private_dns_zone_ids = var.private_endpoint.private_dns_zone_ids
}
}
tags = var.tags
}
# variables.tf
variable "name" {
description = "Name of the Azure Digital Twins instance. Must be globally unique within its Azure DNS namespace."
type = string
validation {
condition = can(regex("^[A-Za-z0-9][A-Za-z0-9-]{1,61}[A-Za-z0-9]$", var.name))
error_message = "name must be 3-63 characters, alphanumeric or hyphen, and start/end with an alphanumeric character."
}
}
variable "resource_group_name" {
description = "Name of the resource group in which to create the Digital Twins instance."
type = string
}
variable "location" {
description = "Azure region for the instance (e.g. westeurope). ADT is only available in a subset of regions; confirm availability for your region."
type = string
}
variable "public_network_access_enabled" {
description = "Whether the instance is reachable over the public internet. Set to false when fronting the instance with a private endpoint."
type = bool
default = true
}
variable "data_owner_principal_ids" {
description = "Object IDs (principals) granted the 'Azure Digital Twins Data Owner' role on the instance for data-plane access. Typically app/service-principal identities."
type = list(string)
default = []
}
variable "private_endpoint" {
description = "Optional private endpoint configuration. When set, creates a private endpoint targeting the 'API' subresource. Pair with public_network_access_enabled = false for full isolation."
type = object({
name = optional(string)
subnet_id = string
private_dns_zone_ids = optional(list(string))
})
default = null
}
variable "tags" {
description = "Tags to apply to all resources created by this module."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "Resource ID of the Digital Twins instance."
value = azurerm_digital_twins_instance.this.id
}
output "name" {
description = "Name of the Digital Twins instance."
value = azurerm_digital_twins_instance.this.name
}
output "host_name" {
description = "Data-plane hostname of the instance. Use 'https://<host_name>' as the ADT endpoint URL for SDK/CLI calls."
value = azurerm_digital_twins_instance.this.host_name
}
output "endpoint_url" {
description = "Fully-qualified HTTPS endpoint URL for the Digital Twins data plane."
value = "https://${azurerm_digital_twins_instance.this.host_name}"
}
output "identity_principal_id" {
description = "Principal (object) ID of the instance's system-assigned managed identity. Grant this to downstream endpoints (Event Hub, Event Grid) for event routing."
value = azurerm_digital_twins_instance.this.identity[0].principal_id
}
output "private_endpoint_id" {
description = "Resource ID of the private endpoint, or null when not created."
value = try(azurerm_private_endpoint.this[0].id, null)
}
How to use it
module "digital_twins" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-digital-twins?ref=v1.0.0"
name = "adt-smartbuilding-prod"
resource_group_name = azurerm_resource_group.iot.name
location = "westeurope"
# Lock down to the VNet only.
public_network_access_enabled = false
# Grant the ingestion function app full data-plane control.
data_owner_principal_ids = [
azurerm_linux_function_app.ingest.identity[0].principal_id,
]
private_endpoint = {
subnet_id = azurerm_subnet.privatelink.id
private_dns_zone_ids = [azurerm_private_dns_zone.adt.id]
}
tags = {
environment = "prod"
workload = "smart-building"
owner = "iot-platform"
}
}
# Downstream: hand the ADT endpoint URL to the ingestion function so it can
# write telemetry into the spatial graph using its managed identity.
resource "azurerm_linux_function_app" "ingest" {
name = "func-adt-ingest-prod"
resource_group_name = azurerm_resource_group.iot.name
location = "westeurope"
service_plan_id = azurerm_service_plan.iot.id
storage_account_name = azurerm_storage_account.iot.name
storage_account_access_key = azurerm_storage_account.iot.primary_access_key
identity {
type = "SystemAssigned"
}
site_config {}
app_settings = {
# Consumed by the Azure Digital Twins SDK in the function code.
ADT_ENDPOINT_URL = module.digital_twins.endpoint_url
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/digital_twins/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-digital-twins?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/digital_twins && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Name of the ADT instance; 3-63 chars, alphanumeric/hyphen, must start and end alphanumeric. |
resource_group_name |
string |
— | Yes | Resource group to create the instance in. |
location |
string |
— | Yes | Azure region; ADT is region-limited, confirm availability. |
public_network_access_enabled |
bool |
true |
No | Allow public internet access. Set false when using a private endpoint. |
data_owner_principal_ids |
list(string) |
[] |
No | Principal object IDs granted the Azure Digital Twins Data Owner role. |
private_endpoint |
object({...}) |
null |
No | Optional private endpoint config (subnet_id, optional name, optional private_dns_zone_ids) targeting the API subresource. |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Digital Twins instance. |
name |
Name of the Digital Twins instance. |
host_name |
Data-plane hostname; prefix with https:// for the endpoint URL. |
endpoint_url |
Fully-qualified HTTPS data-plane endpoint URL. |
identity_principal_id |
Object ID of the system-assigned managed identity, for granting access on event-routing endpoints. |
private_endpoint_id |
Resource ID of the private endpoint, or null when not created. |
Enterprise scenario
A commercial real-estate operator runs Azure Digital Twins as the spatial backbone for 40 office towers. Each building is its own ADT instance provisioned by this module in a per-region resource group, with public_network_access_enabled = false and a private endpoint onto the corporate hub VNet so occupancy, HVAC, and access-control telemetry never traverses the public internet. The building-management Function App’s managed identity is passed in via data_owner_principal_ids, giving it least-privilege data-plane access to update twins, while the instance’s own system-assigned identity (surfaced through identity_principal_id) is granted Sender rights on a shared Event Hub for downstream analytics routing.
Best practices
- Treat RBAC as the only auth model. ADT has no account keys — data-plane access is governed entirely by Azure RBAC. Assign Azure Digital Twins Data Owner sparingly (deployment/admin identities) and prefer Azure Digital Twins Data Reader for query-only consumers; never share a single owner principal across unrelated apps.
- Use the instance’s managed identity for event routing. Wire
identity_principal_idinto role assignments on your Event Hub / Event Grid / Service Bus endpoints rather than embedding connection strings in DTDL endpoints — this keeps secrets out of the graph. - Isolate the data plane with a private endpoint. Set
public_network_access_enabled = false, attach theAPIsubresource private endpoint, and link theprivatelink.digitaltwins.azure.netprivate DNS zone so SDK calls resolve internally. - Right-size and watch cost. ADT billing is driven by API operations, query units, and messages routed — not by instance size. Throttle high-frequency telemetry at the device or ingestion layer and avoid chatty per-property updates so query-unit consumption (and your bill) stays predictable.
- Name and scope per environment. Keep one instance per environment/building and encode that in
name(adt-<workload>-<env>); the spatial graph is stateful and cannot be trivially merged, so resist sharing a single instance across dev and prod. - Pin the module version. Always consume the module with an explicit
?ref=vX.Y.Ztag so identity, RBAC, and networking defaults change only on a deliberate version bump.