Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_virtual_network: validated address space, for_each subnets with service delegations and endpoints, optional DDoS plan and custom DNS, and outputs keyed for downstream NICs, peerings, and Private Endpoints. 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 "virtual_network" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-network?ref=v1.0.0"
name = "..." # VNet name (validated, 2–64 chars).
resource_group_name = "..." # Resource group for the VNet and its subnets.
location = "..." # Azure region.
address_space = ["...", "..."] # One or more CIDR blocks (validated, non-overlapping).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Virtual Network (VNet) is the private, isolated layer-3 network boundary your workloads live in. It owns one or more address spaces (CIDR blocks), carves them into subnets, controls name resolution (Azure-provided DNS or your own DNS servers), and is the anchor you attach peerings, NAT gateways, route tables, NSGs, service endpoints, subnet delegations, and Private Endpoints to. Practically everything with a private IP in Azure — VMs, AKS node pools, App Service / Functions VNet integration, Private Endpoints to PaaS — terminates inside a subnet of a VNet.
The azurerm_virtual_network resource looks deceptively small, but it carries the sharpest edges in Azure networking:
- Address space is hard to change in place. Shrinking or removing a CIDR that already has allocations fails; overlapping address space with a peer or on-prem range silently breaks routing. The plan should be reviewed, not improvised.
- The inline
subnetblock fights you. A VNet supports inline subnet definitions and standaloneazurerm_subnetresources — but mixing them, or letting another tool (AKS, the portal, a Private Endpoint) mutate a subnet the VNet block also declares, causes endless “address_prefix changed” drift. The production-safe pattern is to manage subnets as separateazurerm_subnetresources, which is exactly what this module does. - Subnets are not just CIDRs. Real subnets need service delegations (e.g.
Microsoft.Web/serverFarmsfor App Service integration), service endpoints (Microsoft.Storage,Microsoft.KeyVault), and Private-Endpoint network-policy toggles — all per subnet.
Wrapping all of this in a module gives you one validated, reviewed definition of “how we build a VNet”: CIDRs that are checked for sanity before apply, a for_each map of subnets so adding one is a three-line diff, optional custom DNS and DDoS Network Protection plan attachment, and clean outputs (the VNet id, and a subnet-name → subnet-id map) that every downstream module — NICs, peerings, AKS, Private Endpoints — consumes.
When to use it
- You are building a hub-and-spoke or enterprise-scale landing zone topology and need each hub and spoke VNet codified identically, with consistent CIDR governance and a stable
idto peer against. - A workload needs its own spoke VNet with a handful of purpose-built subnets (web, app, data, Private Endpoints) and you want adding/removing a subnet to be a reviewed, repeatable change rather than portal clicks.
- You run App Service / Functions VNet integration, AKS, or Private Endpoints and need subnets pre-wired with the right delegations, service endpoints, and Private-Endpoint policy settings.
- You want custom DNS (forwarding to a DNS Private Resolver or on-prem AD) applied consistently across every VNet, and optional DDoS Network Protection attached on internet-facing VNets.
Reach for Microsoft’s Azure/avm-res-network-virtualnetwork/azurerm AVM module instead only when you specifically want the full upstream feature surface (in-module peering, encryption, the complete attribute set) maintained for you; this module owns the VNet plus its subnets and stays small enough to read in one sitting.
Module structure
terraform-module-azure-virtual-network/
├── 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
locals {
tags = merge(
{
managed_by = "terraform"
module = "azure-virtual-network"
},
var.tags,
)
}
resource "azurerm_virtual_network" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
address_space = var.address_space
# Custom DNS servers (e.g. a DNS Private Resolver inbound endpoint or on-prem
# AD DNS). Leaving this empty falls back to Azure-provided DNS (168.63.129.16).
dns_servers = var.dns_servers
# Attach a DDoS Network Protection plan on internet-exposed VNets. The plan
# itself is created once per region and shared (it is not cheap), so we only
# reference it here and gate the block on a flag.
dynamic "ddos_protection_plan" {
for_each = var.ddos_protection_plan_id == null ? [] : [1]
content {
id = var.ddos_protection_plan_id
enable = true
}
}
tags = local.tags
}
# Subnets are managed as separate resources (NOT the inline `subnet` block) so
# that tools which legitimately mutate subnets at runtime — AKS, App Service
# integration, Private Endpoints — do not cause perpetual address_prefix drift.
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = each.value.address_prefixes
# Service endpoints route subnet traffic to PaaS over the Azure backbone and
# let you lock those services to this subnet (e.g. Storage, Key Vault, SQL).
service_endpoints = each.value.service_endpoints
# Opt subnets out of Private-Endpoint / Private-Link network policies. A
# subnet that *hosts* Private Endpoints typically needs PE policies disabled.
private_endpoint_network_policies = each.value.private_endpoint_network_policies
# Optional delegation, e.g. "Microsoft.Web/serverFarms" for App Service /
# Functions regional VNet integration, or "Microsoft.ContainerInstance/...".
dynamic "delegation" {
for_each = each.value.delegation == null ? [] : [each.value.delegation]
content {
name = delegation.value.name
service_delegation {
name = delegation.value.service_name
actions = delegation.value.actions
}
}
}
}
variables.tf
variable "name" {
description = "Virtual network name. Follow your convention, e.g. vnet-<workload>-<env>-<region>."
type = string
validation {
# VNet name: 2-64 chars; letters, digits, hyphen, period, underscore;
# must start with a letter/digit and end with a letter, digit, or underscore.
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 a letter/digit, and end with a letter, digit, or underscore."
}
}
variable "resource_group_name" {
description = "Name of the resource group the VNet and its subnets are created in."
type = string
}
variable "location" {
description = "Azure region for the VNet (e.g. centralindia, eastus)."
type = string
}
variable "address_space" {
description = "List of CIDR blocks for the VNet (e.g. [\"10.20.0.0/16\"]). Must not overlap peers or on-prem ranges."
type = list(string)
validation {
condition = length(var.address_space) > 0
error_message = "address_space must contain at least one CIDR block."
}
validation {
condition = alltrue([for c in var.address_space : can(cidrhost(c, 0))])
error_message = "Each address_space entry must be a valid IPv4/IPv6 CIDR (e.g. 10.20.0.0/16)."
}
}
variable "dns_servers" {
description = "Custom DNS server IPs for the VNet. Empty = Azure-provided DNS (168.63.129.16)."
type = list(string)
default = []
validation {
condition = alltrue([
for ip in var.dns_servers :
can(regex("^(\\d{1,3}\\.){3}\\d{1,3}$", ip))
])
error_message = "Each dns_servers entry must be a valid IPv4 address."
}
}
variable "ddos_protection_plan_id" {
description = "Resource ID of a DDoS Network Protection plan to attach. null = no plan (default)."
type = string
default = null
validation {
condition = var.ddos_protection_plan_id == null || can(regex(
"^/subscriptions/.+/providers/Microsoft.Network/ddosProtectionPlans/.+$",
var.ddos_protection_plan_id
))
error_message = "ddos_protection_plan_id must be a DDoS plan resource ID or null."
}
}
variable "subnets" {
description = <<-EOT
Map of subnets keyed by subnet name. Each value:
address_prefixes - list of CIDRs carved from address_space (required)
service_endpoints - e.g. ["Microsoft.Storage", "Microsoft.KeyVault"] (default [])
private_endpoint_network_policies - "Enabled" | "Disabled" | "NetworkSecurityGroupEnabled"
| "RouteTableEnabled" (default "Enabled")
delegation - optional { name, service_name, actions } block
EOT
type = map(object({
address_prefixes = list(string)
service_endpoints = optional(list(string), [])
private_endpoint_network_policies = optional(string, "Enabled")
delegation = optional(object({
name = string
service_name = string
actions = list(string)
}))
}))
default = {}
validation {
condition = alltrue([
for s in values(var.subnets) :
length(s.address_prefixes) > 0 &&
alltrue([for c in s.address_prefixes : can(cidrhost(c, 0))])
])
error_message = "Every subnet must have at least one valid CIDR in address_prefixes."
}
validation {
condition = alltrue([
for s in values(var.subnets) : contains(
["Enabled", "Disabled", "NetworkSecurityGroupEnabled", "RouteTableEnabled"],
s.private_endpoint_network_policies
)
])
error_message = "private_endpoint_network_policies must be Enabled, Disabled, NetworkSecurityGroupEnabled, or RouteTableEnabled."
}
}
variable "tags" {
description = "Tags merged with module defaults and applied to the VNet."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Virtual network resource ID — use for peerings, diagnostic settings, and locks."
value = azurerm_virtual_network.this.id
}
output "name" {
description = "Virtual network name."
value = azurerm_virtual_network.this.name
}
output "address_space" {
description = "CIDR blocks assigned to the VNet."
value = azurerm_virtual_network.this.address_space
}
output "guid" {
description = "Immutable GUID of the VNet (handy for cross-tenant peering references)."
value = azurerm_virtual_network.this.guid
}
output "subnet_ids" {
description = "Map of subnet name => subnet resource ID. Feed into NICs, Private Endpoints, AKS, etc."
value = { for k, s in azurerm_subnet.this : k => s.id }
}
output "subnet_address_prefixes" {
description = "Map of subnet name => list of address prefixes."
value = { for k, s in azurerm_subnet.this : k => s.address_prefixes }
}
How to use it
module "virtual_network" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-network?ref=v1.0.0"
name = "vnet-payments-prod-cin"
resource_group_name = module.rg.name
location = module.rg.location
address_space = ["10.20.0.0/16"]
# Forward to a DNS Private Resolver inbound endpoint for hybrid name resolution.
dns_servers = ["10.10.0.4"]
# Internet-facing spoke -> attach the shared regional DDoS plan.
ddos_protection_plan_id = data.azurerm_network_ddos_protection_plan.regional.id
subnets = {
snet-web = {
address_prefixes = ["10.20.1.0/24"]
service_endpoints = ["Microsoft.Storage"]
}
# App Service / Functions regional VNet integration needs a delegated subnet.
snet-app = {
address_prefixes = ["10.20.2.0/24"]
delegation = {
name = "appsvc"
service_name = "Microsoft.Web/serverFarms"
actions = ["Microsoft.Network/virtualNetworks/subnets/action"]
}
}
# Subnet that hosts Private Endpoints -> disable PE network policies.
snet-pep = {
address_prefixes = ["10.20.3.0/24"]
service_endpoints = ["Microsoft.KeyVault"]
private_endpoint_network_policies = "Disabled"
}
}
tags = {
env = "prod"
workload = "payments"
cost_center = "FIN-204"
owner = "platform@kloudvin.com"
}
}
# Downstream: drop a Private Endpoint into the PE subnet using the output map.
resource "azurerm_private_endpoint" "kv" {
name = "pe-kv-payments-prod"
resource_group_name = module.rg.name
location = module.rg.location
subnet_id = module.virtual_network.subnet_ids["snet-pep"]
private_service_connection {
name = "psc-kv"
private_connection_resource_id = azurerm_key_vault.payments.id
is_manual_connection = false
subresource_names = ["vault"]
}
}
Pin the module with
?ref=<tag>so a stack never silently picks up a breaking module change — doubly important for a VNet, where an unintendedaddress_spaceedit can ripple through peerings and routing.
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/virtual_network/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-network?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
address_space = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/virtual_network && 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 | VNet name (validated, 2–64 chars). |
resource_group_name |
string |
— | Yes | Resource group for the VNet and its subnets. |
location |
string |
— | Yes | Azure region. |
address_space |
list(string) |
— | Yes | One or more CIDR blocks (validated, non-overlapping). |
dns_servers |
list(string) |
[] |
No | Custom DNS IPs; empty uses Azure-provided DNS. |
ddos_protection_plan_id |
string |
null |
No | DDoS Network Protection plan ID to attach; null = none. |
subnets |
map(object) |
{} |
No | Subnets keyed by name (prefixes, service endpoints, PE policies, optional delegation). |
tags |
map(string) |
{} |
No | Tags merged with module defaults. |
Outputs
| Name | Description |
|---|---|
id |
VNet resource ID — for peerings, diagnostics, and locks. |
name |
VNet name. |
address_space |
CIDR blocks assigned to the VNet. |
guid |
Immutable VNet GUID (useful for cross-tenant peering). |
subnet_ids |
Map of subnet name → subnet ID for NICs, Private Endpoints, AKS. |
subnet_address_prefixes |
Map of subnet name → its address prefixes. |
Enterprise scenario
A fintech platform team runs a hub-and-spoke landing zone in centralindia. The connectivity subscription’s hub VNet (10.0.0.0/16, with GatewaySubnet and AzureFirewallSubnet) and every workload spoke are all built from this one module, so CIDR allocation, custom DNS pointing at the central DNS Private Resolver, and the shared DDoS plan are identical everywhere. When the payments team needs a new spoke, they add a module "virtual_network" block with a /16 from the IPAM-reserved range and four subnets; a separate peering module consumes the new spoke’s id output to wire bidirectional peering to the hub, and Private Endpoints land in the snet-pep subnet via the subnet_ids map — the whole spoke is a single reviewed PR with zero portal clicks.
Best practices
- Govern CIDRs centrally and never overlap. Allocate non-overlapping
address_spacefrom an IPAM source before you write the module call; overlapping ranges with a peer or on-prem break routing in ways Terraform won’t warn you about. Size subnets with headroom — Azure reserves 5 IPs per subnet. - Manage subnets as separate resources, never inline. This module already does this; keep it that way so AKS, App Service integration, and Private Endpoints can mutate subnet runtime state without triggering perpetual
address_prefixdrift. - Disable Private-Endpoint network policies only on PE-hosting subnets. Set
private_endpoint_network_policies = "Disabled"on the subnet that holds Private Endpoints, and leave itEnabledelsewhere so NSGs and UDRs still apply to normal workloads. - Attach DDoS Network Protection only on internet-facing VNets, and share the plan. A DDoS plan is billed per plan, not per VNet — create one per region and reference its ID here; don’t pay for one per spoke.
- Use custom DNS deliberately for hybrid resolution. Point
dns_serversat a DNS Private Resolver or AD DNS for on-prem name resolution and Private DNS zone integration; an empty list (Azure-provided DNS) is correct for fully cloud-native VNets. - Name by convention and lock the hub. Adopt
vnet-<workload>-<env>-<region>andsnet-<role>and enforce it with thenamevalidation; apply aCanNotDeletemanagement lock on hub and shared VNets so a strayterraform destroyor cleanup script can’t sever connectivity for every spoke.
Part of the KloudVin Terraform module library. Pair this with the Subnet/NSG, VNet Peering, and Private DNS Zone modules — they all attach to the id and subnet_ids this module outputs.