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:
- Pin the scope (management group IDs and/or subscription IDs) and the enabled feature set as deliberate, reviewed inputs rather than click-ops in the portal.
- Ship the manager together with its network groups so the day-one artifact is actually usable by downstream connectivity/security configs, instead of an empty manager you have to populate later.
- Standardize naming, location, and tagging across every landing zone that needs centralized networking, and expose the manager
idand network-group IDs as outputs so config modules can attach to them.
When to use it
- You have moved past a static hub-and-spoke and want VNets to auto-join a topology based on tags or naming, via dynamic membership network groups.
- You need security admin rules that enforce baseline controls (block SSH/RDP from the internet, deny a banned port range) org-wide, evaluated before any team’s NSG and not editable by them.
- You are running a landing-zone / management-group model and want one AVNM instance per platform scope, deployed identically by CI across environments.
- You are about to write
azurerm_network_manager_connectivity_configurationor..._security_admin_configurationand need a manager plus network groups to point them at first.
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 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/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
- Be deliberate about
scope_accesses. Enable only the features you have configurations for. Turning onSecurityAdminand then deploying a broad deny rule before app teams know about it will cause outages — stage admin rules in a non-prod manager first and use the rule collection’sAllow/AlwaysAllowcarefully. - Prefer management-group scope over enumerating subscriptions. Scoping to
mg-platformmeans new subscriptions placed under it are covered automatically; a static list ofsubscription_idsrots and silently leaves new subs ungoverned. - Apply connectivity/security configs in a controlled
deploymentstep. Creating the configuration object does nothing until it is deployed to a region. Keep deployments (azurerm_network_manager_deployment) in a separate, gated pipeline stage so a topology change is an explicit, reviewable action — not a side effect ofterraform apply. - Mix static and dynamic membership intentionally. Pin platform/hub-adjacent VNets as
static_member_vnet_idsfor determinism, and use Azure Policy for tag-based dynamic membership of the long tail of app VNets. Don’t try to enumerate dozens of spokes statically. - One manager per platform scope, named for it. Use names like
avnm-platform-prod/avnm-platform-nonprodso the blast radius of each manager is obvious; never share one manager across prod and non-prod scopes. - Cost is effectively zero — governance risk is not. AVNM itself has no per-hour charge, so the constraint is operational: gate the module behind PR review, tag the manager with an owner, and treat security admin rule changes like firewall changes, because at scale they behave like one.