IaC Azure

Terraform Module: Azure Virtual Network — one governed VNet + subnets you can wire a whole landing zone onto

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:

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

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 unintended address_space edit 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 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/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


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.

TerraformAzureVirtual NetworkModuleIaC
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