IaC Azure

Terraform Module: Azure Virtual Network Manager — centralized, policy-driven network governance at scale

Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Virtual Network Manager: scope it to a management group, enable Connectivity and Security Admin features, and ship network groups in one block. 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 "network_manager" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-network-manager?ref=v1.0.0"

  name                = "..."  # Name of the Virtual Network Manager (2–64 chars, valida…
  location            = "..."  # Azure region for the manager resource.
  resource_group_name = "..."  # Existing resource group to hold the manager.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Virtual Network Manager (AVNM) is the control plane Microsoft gives you when a handful of hand-wired VNet peerings stops scaling. Instead of building an N×N mesh of azurerm_virtual_network_peering resources by hand, you define a network manager scoped to a management group (or set of subscriptions), group your VNets dynamically, and then push connectivity configurations (hub-and-spoke or full mesh) and security admin rules that sit above NSGs and cannot be overridden by application teams.

The thing worth understanding is that the azurerm_network_manager resource itself is almost trivial — it is the scope and the scope_accesses (the enabled features: Connectivity, SecurityAdmin, and the newer Routing) that carry all the meaning, and getting them wrong is the difference between AVNM being able to manage your topology and it being an inert empty object. Wrapping it in a module lets you:

When to use it

If you only have two or three VNets and a single peering pair, skip AVNM — a couple of azurerm_virtual_network_peering resources are cheaper and simpler.

Module structure

terraform-module-azure-network-manager/
├── 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

# The Virtual Network Manager itself. The scope (mgmt groups / subscriptions)
# and the enabled feature set (scope_accesses) are what make it useful.
resource "azurerm_network_manager" "this" {
  name                = var.name
  location            = var.location
  resource_group_name = var.resource_group_name
  description         = var.description
  scope_accesses      = var.scope_accesses
  tags                = var.tags

  scope {
    management_group_ids = var.management_group_ids
    subscription_ids     = var.subscription_ids
  }
}

# Network groups: the logical buckets of VNets that connectivity and
# security-admin configurations later target. One per entry in var.network_groups.
resource "azurerm_network_manager_network_group" "this" {
  for_each = var.network_groups

  name               = each.key
  network_manager_id = azurerm_network_manager.this.id
  description        = each.value.description
}

# Optional static membership: explicitly pin known VNets into a network group.
# (Dynamic, tag-based membership is done with an Azure Policy created outside
#  this module; static members are deterministic and great for platform VNets.)
locals {
  static_members = merge([
    for group_name, group in var.network_groups : {
      for vnet_id in group.static_member_vnet_ids :
      "${group_name}|${vnet_id}" => {
        group_name = group_name
        vnet_id    = vnet_id
      }
    }
  ]...)
}

resource "azurerm_network_manager_static_member" "this" {
  for_each = local.static_members

  name                      = substr(replace(sha1(each.value.vnet_id), "/(.{1,24}).*/", "$1"), 0, 24)
  network_group_id          = azurerm_network_manager_network_group.this[each.value.group_name].id
  target_virtual_network_id = each.value.vnet_id
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the Virtual Network Manager instance."

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}[a-zA-Z0-9_]$", var.name))
    error_message = "name must be 2-64 chars, start with alphanumeric, and contain only letters, numbers, '.', '_' or '-'."
  }
}

variable "location" {
  type        = string
  description = "Azure region for the network manager resource (e.g. centralindia)."
}

variable "resource_group_name" {
  type        = string
  description = "Name of an existing resource group to hold the network manager."
}

variable "description" {
  type        = string
  description = "Free-text description of the network manager's purpose."
  default     = "Centralized Azure Virtual Network Manager managed by Terraform."
}

variable "scope_accesses" {
  type        = list(string)
  description = "Enabled feature set. Any of: Connectivity, SecurityAdmin, Routing."
  default     = ["Connectivity", "SecurityAdmin"]

  validation {
    condition = length(var.scope_accesses) > 0 && alltrue([
      for a in var.scope_accesses : contains(["Connectivity", "SecurityAdmin", "Routing"], a)
    ])
    error_message = "scope_accesses must be non-empty and only contain: Connectivity, SecurityAdmin, Routing."
  }
}

variable "management_group_ids" {
  type        = list(string)
  description = "Management group resource IDs the manager governs. Provide this and/or subscription_ids."
  default     = []
}

variable "subscription_ids" {
  type        = list(string)
  description = "Subscription resource IDs the manager governs. Provide this and/or management_group_ids."
  default     = []
}

variable "network_groups" {
  type = map(object({
    description            = optional(string, "")
    static_member_vnet_ids = optional(list(string), [])
  }))
  description = "Map of network groups (key = group name) with optional static VNet members."
  default     = {}
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to the network manager resource."
  default     = {}
}

# Guard: the manager must govern at least one scope, otherwise it is inert.
locals {
  _scope_check = (length(var.management_group_ids) + length(var.subscription_ids)) > 0 ? true : tobool(
    "At least one of management_group_ids or subscription_ids must be set."
  )
}

outputs.tf

output "id" {
  description = "Resource ID of the Virtual Network Manager."
  value       = azurerm_network_manager.this.id
}

output "name" {
  description = "Name of the Virtual Network Manager."
  value       = azurerm_network_manager.this.name
}

output "scope_accesses" {
  description = "The enabled feature set on the manager (Connectivity / SecurityAdmin / Routing)."
  value       = azurerm_network_manager.this.scope_accesses
}

output "network_group_ids" {
  description = "Map of network group name => network group resource ID."
  value       = { for k, v in azurerm_network_manager_network_group.this : k => v.id }
}

How to use it

module "virtual_network_manager" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-network-manager?ref=v1.0.0"

  name                = "avnm-platform-prod"
  location            = "centralindia"
  resource_group_name = "rg-connectivity-prod"
  description         = "Platform network manager for the prod landing zone."

  # Govern everything under the Platform management group.
  management_group_ids = [
    "/providers/Microsoft.Management/managementGroups/mg-platform"
  ]

  scope_accesses = ["Connectivity", "SecurityAdmin"]

  network_groups = {
    "ng-spokes-prod" = {
      description = "All production spoke VNets — targeted by the hub-and-spoke connectivity config."
      static_member_vnet_ids = [
        "/subscriptions/0000-1111/resourceGroups/rg-app/providers/Microsoft.Network/virtualNetworks/vnet-app-prod"
      ]
    }
    "ng-all-vnets" = {
      description = "Every VNet in scope — targeted by baseline security admin rules."
    }
  }

  tags = {
    environment = "prod"
    owner       = "platform-networking"
  }
}

# Downstream: a hub-and-spoke connectivity configuration that consumes the
# manager id and one of the network-group ids this module produced.
resource "azurerm_network_manager_connectivity_configuration" "hub_spoke" {
  name                  = "cc-hub-spoke-prod"
  network_manager_id    = module.virtual_network_manager.id
  connectivity_topology = "HubAndSpoke"

  applies_to_group {
    group_connectivity = "DirectlyConnected"
    network_group_id   = module.virtual_network_manager.network_group_ids["ng-spokes-prod"]
  }

  hub {
    resource_id   = azurerm_virtual_network.hub.id
    resource_type = "Microsoft.Network/virtualNetworks"
  }
}

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/network_manager/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-network-manager?ref=v1.0.0"
}

inputs = {
  name = "..."
  location = "..."
  resource_group_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/network_manager && 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 Virtual Network Manager (2–64 chars, validated).
location string Yes Azure region for the manager resource.
resource_group_name string Yes Existing resource group to hold the manager.
description string "Centralized Azure Virtual Network Manager managed by Terraform." No Free-text description of the manager’s purpose.
scope_accesses list(string) ["Connectivity", "SecurityAdmin"] No Enabled features: any of Connectivity, SecurityAdmin, Routing.
management_group_ids list(string) [] No* Management group IDs the manager governs.
subscription_ids list(string) [] No* Subscription IDs the manager governs.
network_groups map(object) {} No Network groups keyed by name, with optional description and static_member_vnet_ids.
tags map(string) {} No Tags applied to the manager resource.

* At least one of management_group_ids or subscription_ids must be non-empty, or the module fails validation.

Outputs

Name Description
id Resource ID of the Virtual Network Manager.
name Name of the Virtual Network Manager.
scope_accesses The enabled feature set on the manager.
network_group_ids Map of network group name to network group resource ID.

Enterprise scenario

A multinational running ~40 spoke VNets across six subscriptions under an mg-platform management group uses this module once per environment to stand up avnm-platform-prod. The platform team enables Connectivity to deploy a single HubAndSpoke configuration against the ng-spokes-prod network group — so any new application VNet tagged for prod is auto-peered to the regional hub without a single hand-written peering — and SecurityAdmin to push a baseline rule set (deny inbound SSH/RDP from the internet, block the legacy SMB port range) against ng-all-vnets, rules that application teams physically cannot override in their own NSGs. When a seventh subscription is onboarded, they add its ID to management_group_ids scope coverage and re-apply, and the entire governance posture extends to it automatically.

Best practices

TerraformAzureVirtual Network ManagerModuleIaC
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