IaC Azure

Terraform Module: Azure Digital Twins — a reusable IoT spatial-graph instance with RBAC and private networking

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

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 configlive/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 configlive/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

TerraformAzureDigital TwinsModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading