Azure Dev Center is the control plane behind Microsoft Dev Box and Azure Deployment Environments. It is where a platform team centralises the catalogs, environment types, and identity that developers then consume self-service — without anyone handing out subscription Owner rights. This article packages a Dev Center plus one or more projects into a single, version-pinned Terraform module so the whole “developer self-service” foundation is reproducible and reviewable.
Quick take — Wrap azurerm_dev_center and azurerm_dev_center_project in a reusable Terraform module to give platform teams governed, identity-backed self-service developer environments on Azure. 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 "dev_center" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-dev-center?ref=v1.0.0"
name = "..." # Name of the Dev Center (3-63 chars, validated).
resource_group_name = "..." # Resource group holding the Dev Center and projects.
location = "..." # Azure region for the Dev Center and projects.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
A Dev Center on its own does very little until you attach projects to it. The azurerm_dev_center resource is the top-level container that holds shared configuration — its system-assigned managed identity, catalogs, network connections, and Dev Box / environment definitions — while each azurerm_dev_center_project is the unit that a development team is actually granted access to. Projects inherit from the Dev Center but carry their own limits (for example, the maximum number of Dev Boxes a single developer may create) and their own RBAC.
Wrapping these two resources in a module matters because the relationship between them is easy to get wrong by hand: the project must reference the Dev Center’s resource ID, the Dev Center’s managed identity must exist before you can grant it rights on target subscriptions, and naming has to stay consistent so downstream pools and environment types resolve. The module fixes the wiring once, validates the inputs (region, identity type, per-developer Dev Box cap), and exposes the Dev Center ID, the project IDs, and — crucially — the managed identity principal ID that you will need for role assignments elsewhere in your estate.
When to use it
- You are standing up Microsoft Dev Box and need a governed Dev Center + project before you can define Dev Box pools and definitions.
- You are rolling out Azure Deployment Environments so app teams can self-serve ephemeral sandbox/test/prod-like environments from a curated catalog.
- You run a platform engineering function and want every team’s developer environment to come from the same audited, version-pinned definition rather than click-ops in the portal.
- You need the Dev Center’s managed identity wired up so it can deploy into target subscriptions, and you want that principal ID as a clean Terraform output for
azurerm_role_assignment.
If you only ever need one throwaway Dev Center for a quick demo, the raw resources are fine — the module earns its keep when you have multiple projects, multiple environments, or governance requirements.
Module structure
terraform-module-azure-dev-center/
├── 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
resource "azurerm_dev_center" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
identity {
type = var.identity_type
identity_ids = var.identity_type == "UserAssigned" || var.identity_type == "SystemAssigned, UserAssigned" ? var.identity_ids : null
}
tags = var.tags
}
# Projects attached to the Dev Center. Each project is what a dev team is
# granted access to; it references the Dev Center by ID and sets its own
# per-developer Dev Box limit.
resource "azurerm_dev_center_project" "this" {
for_each = var.projects
name = each.key
resource_group_name = var.resource_group_name
location = var.location
dev_center_id = azurerm_dev_center.this.id
description = each.value.description
maximum_dev_boxes_per_user = each.value.maximum_dev_boxes_per_user
tags = merge(var.tags, each.value.tags)
}
variables.tf
variable "name" {
description = "Name of the Dev Center."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9-.]{2,62}$", var.name))
error_message = "Dev Center name must be 3-63 chars, start alphanumeric, and contain only letters, numbers, hyphens or periods."
}
}
variable "resource_group_name" {
description = "Resource group that will hold the Dev Center and its projects."
type = string
}
variable "location" {
description = "Azure region for the Dev Center and projects (e.g. centralindia, eastus)."
type = string
}
variable "identity_type" {
description = "Managed identity type for the Dev Center."
type = string
default = "SystemAssigned"
validation {
condition = contains(
["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"],
var.identity_type
)
error_message = "identity_type must be one of: SystemAssigned, UserAssigned, or 'SystemAssigned, UserAssigned'."
}
}
variable "identity_ids" {
description = "User-assigned managed identity resource IDs. Required when identity_type includes UserAssigned."
type = list(string)
default = []
}
variable "projects" {
description = "Map of Dev Center projects to create, keyed by project name."
type = map(object({
description = optional(string, "")
maximum_dev_boxes_per_user = optional(number)
tags = optional(map(string), {})
}))
default = {}
validation {
condition = alltrue([
for p in values(var.projects) :
p.maximum_dev_boxes_per_user == null || (p.maximum_dev_boxes_per_user >= 0 && p.maximum_dev_boxes_per_user <= 100)
])
error_message = "maximum_dev_boxes_per_user must be between 0 and 100 when set."
}
}
variable "tags" {
description = "Tags applied to the Dev Center and all projects."
type = map(string)
default = {}
}
outputs.tf
output "dev_center_id" {
description = "Resource ID of the Dev Center."
value = azurerm_dev_center.this.id
}
output "dev_center_name" {
description = "Name of the Dev Center."
value = azurerm_dev_center.this.name
}
output "dev_center_uri" {
description = "The developer portal URI of the Dev Center."
value = azurerm_dev_center.this.dev_center_uri
}
output "principal_id" {
description = "Principal (object) ID of the Dev Center's system-assigned managed identity. Use for role assignments on target subscriptions."
value = try(azurerm_dev_center.this.identity[0].principal_id, null)
}
output "project_ids" {
description = "Map of project name => project resource ID."
value = { for k, p in azurerm_dev_center_project.this : k => p.id }
}
output "project_dev_center_uris" {
description = "Map of project name => project's Dev Center developer portal URI."
value = { for k, p in azurerm_dev_center_project.this : k => p.dev_center_uri }
}
How to use it
module "dev_center" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-dev-center?ref=v1.0.0"
name = "dc-platform-prod"
resource_group_name = azurerm_resource_group.platform.name
location = "centralindia"
identity_type = "SystemAssigned"
projects = {
"proj-payments" = {
description = "Payments squad - Dev Box + deployment environments"
maximum_dev_boxes_per_user = 2
tags = { squad = "payments" }
}
"proj-data-platform" = {
description = "Data platform team sandboxes"
maximum_dev_boxes_per_user = 3
}
}
tags = {
environment = "prod"
owner = "platform-engineering"
cost_center = "CC-4821"
}
}
# Downstream: grant the Dev Center's managed identity the rights it needs to
# deploy Azure Deployment Environments into the target subscription.
resource "azurerm_role_assignment" "dc_deploy" {
scope = data.azurerm_subscription.target.id
role_definition_name = "Owner"
principal_id = module.dev_center.principal_id
}
# Downstream: a Dev Box pool that lives in one of the module's projects,
# referenced by its output ID.
resource "azurerm_dev_center_dev_box_definition" "vs2022" {
name = "vs2022-win11"
location = "centralindia"
dev_center_id = module.dev_center.dev_center_id
image_reference_id = "${module.dev_center.dev_center_id}/galleries/default/images/microsoftwindowsdesktop_windows-ent-cpc_win11-23h2-ent-cpc"
sku_name = "general_i_8c32gb256ssd_v2"
}
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/dev_center/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-dev-center?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/dev_center && 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 Dev Center (3-63 chars, validated). |
| resource_group_name | string |
— | Yes | Resource group holding the Dev Center and projects. |
| location | string |
— | Yes | Azure region for the Dev Center and projects. |
| identity_type | string |
"SystemAssigned" |
No | Managed identity type: SystemAssigned, UserAssigned, or "SystemAssigned, UserAssigned". |
| identity_ids | list(string) |
[] |
No | User-assigned identity resource IDs; required when identity_type includes UserAssigned. |
| projects | map(object) |
{} |
No | Map of projects keyed by name; each supports description, maximum_dev_boxes_per_user, and tags. |
| tags | map(string) |
{} |
No | Tags applied to the Dev Center and all projects. |
Outputs
| Name | Description |
|---|---|
| dev_center_id | Resource ID of the Dev Center. |
| dev_center_name | Name of the Dev Center. |
| dev_center_uri | Developer portal URI of the Dev Center. |
| principal_id | Principal ID of the Dev Center’s system-assigned managed identity (for role assignments). |
| project_ids | Map of project name to project resource ID. |
| project_dev_center_uris | Map of project name to the project’s developer portal URI. |
Enterprise scenario
A fintech platform team uses this module to give each product squad its own self-service developer experience without ever granting subscription access. They deploy one dc-platform-prod Dev Center in centralindia with a system-assigned identity, then define one project per squad with maximum_dev_boxes_per_user = 2 to cap idle Dev Box spend. The module’s principal_id output feeds an azurerm_role_assignment that grants the Dev Center identity Owner on a dedicated “deployment-environments” subscription, so app teams can spin up curated, policy-compliant sandboxes via Azure Deployment Environments while finance keeps every resource tagged with the squad and cost center.
Best practices
- Cap Dev Boxes per user. Always set
maximum_dev_boxes_per_useron each project — an unbounded default lets a single developer stand up many always-billed Dev Box VMs, which is the fastest way to blow the budget. - Prefer system-assigned identity, then scope it tightly. Let the module create the system-assigned identity and grant it the least role it needs on the deployment subscription only (not the production estate). Owner is sometimes required for Deployment Environments, so isolate that subscription.
- Separate the Dev Center subscription from target subscriptions. Keep the Dev Center in a platform/management subscription and have it deploy into separate, blast-radius-limited subscriptions so a misconfigured catalog can’t touch production.
- Use consistent, validated naming. Stick to a
dc-<scope>-<env>/proj-<team>convention (the module validates the Dev Center name) so downstream Dev Box pools, definitions, and environment types resolve predictably. - Tag for chargeback. Pass
cost_center,owner, andenvironmenttags through the module; Dev Box and environment costs accrue per project, and tags are what make per-squad showback possible. - Pin the module ref and the provider. Consume the module at an immutable
?ref=v1.0.0tag and keepazurermpinned to~> 4.0; Dev Center attributes have shifted across provider majors, so unpinned upgrades can force-replace live developer environments.