IaC Azure

Terraform Module: Azure Network Interface — one governed NIC with IP configs, NSG/ASG/LB associations wired in

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_network_interface: validated ip_configuration blocks, optional NSG and Application Security Group associations, load balancer backend pool membership, accelerated networking and IP forwarding toggles. 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 "nic" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-network-interface?ref=v1.0.0"

  name                = "..."   # NIC name (e.g. nic-web-prod-01).
  resource_group_name = "..."   # Resource group for the NIC.
  location            = "..."   # Azure region (must match the subnet's VNet).

  ip_configurations = [{
    name      = "ipconfig1"     # IP config name (referenced by associations).
    subnet_id = "..."           # Subnet the NIC lands in.
    # private_ip_address_allocation defaults to "Dynamic".
  }]
}

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

A NIC is not attached to a VM by this module. You attach it to a VM separately via the VM’s network_interface_ids argument — keeping the NIC’s lifecycle independent of the compute it serves.

What this module is

An Azure Network Interface (NIC) (azurerm_network_interface) is the resource that gives a VM (or other compute) its presence on a virtual network: it owns one or more IP configurations, each binding the NIC to a subnet and assigning a private IP (Dynamic from the subnet pool, or a pinned Static address), optionally fronting a public IP. The NIC is also where you toggle accelerated networking (SR-IOV offload for higher throughput and lower latency), IP forwarding (required for NVAs/firewalls that route traffic not addressed to themselves), and NIC-level DNS servers.

What trips people up is that the NIC by itself is only half the story. The security and load-balancing wiring lives in separate association resources, not as inline blocks:

Managing these as standalone resources is the correct, production-safe pattern — but it means every team that hand-rolls a NIC has to remember three extra resource types and get the ip_configuration_name references right. Wrapping it in a module collapses that to a single call: you describe the IP configs, optionally pass an NSG ID, a list of ASG IDs, and a list of backend pools, and the module creates the NIC plus exactly the associations you asked for — with validations that catch a Static allocation missing its address, or a backend pool reference pointing at a non-existent IP config.

When to use it

Reach for the VM resource’s inline NIC only for a throwaway lab box. For anything that lives across environments — or anything joined to an NSG, ASG, or load balancer — a governed NIC module keeps the wiring honest.

Module structure

terraform-module-azure-network-interface/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # NIC + NSG / ASG / backend pool associations
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # NIC id, private IP(s), and association ids

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-network-interface"
    },
    var.tags,
  )

  # The first ip_configuration is treated as primary when more than one exists;
  # Azure requires exactly one primary across the set.
  ip_config_count = length(var.ip_configurations)

  # Build the backend-pool associations as a flattened map keyed by a stable
  # composite so adding/removing a pool does not churn unrelated associations.
  backend_pool_associations = {
    for assoc in flatten([
      for cfg in var.ip_configurations : [
        for pool_id in cfg.backend_address_pool_ids : {
          key                   = "${cfg.name}|${pool_id}"
          ip_configuration_name = cfg.name
          pool_id               = pool_id
        }
      ]
    ]) : assoc.key => assoc
  }
}

resource "azurerm_network_interface" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location

  # SR-IOV offload — only supported on certain VM sizes.
  accelerated_networking_enabled = var.accelerated_networking_enabled

  # Required for NVAs/firewalls that forward traffic not addressed to themselves.
  ip_forwarding_enabled = var.ip_forwarding_enabled

  # NIC-level DNS overrides the VNet's DNS servers when set.
  dns_servers = var.dns_servers

  dynamic "ip_configuration" {
    for_each = var.ip_configurations
    content {
      name                          = ip_configuration.value.name
      subnet_id                     = ip_configuration.value.subnet_id
      private_ip_address_allocation = ip_configuration.value.private_ip_address_allocation
      private_ip_address            = ip_configuration.value.private_ip_address_allocation == "Static" ? ip_configuration.value.private_ip_address : null
      public_ip_address_id          = ip_configuration.value.public_ip_address_id

      # Exactly one IP config must be primary; default the first when unset.
      primary = coalesce(
        ip_configuration.value.primary,
        ip_configuration.key == 0,
      )
    }
  }

  tags = local.tags
}

# Bind an NSG directly to the NIC (finer-grained than subnet-level NSGs).
resource "azurerm_network_interface_security_group_association" "this" {
  count = var.network_security_group_id == null ? 0 : 1

  network_interface_id      = azurerm_network_interface.this.id
  network_security_group_id = var.network_security_group_id
}

# Join the NIC to one or more Application Security Groups for micro-segmentation.
resource "azurerm_network_interface_application_security_group_association" "this" {
  for_each = toset(var.application_security_group_ids)

  network_interface_id          = azurerm_network_interface.this.id
  application_security_group_id  = each.value
}

# Register IP configurations into load balancer backend pools.
resource "azurerm_network_interface_backend_address_pool_association" "this" {
  for_each = local.backend_pool_associations

  network_interface_id    = azurerm_network_interface.this.id
  ip_configuration_name   = each.value.ip_configuration_name
  backend_address_pool_id = each.value.pool_id
}

variables.tf

variable "name" {
  description = "Network interface name. Follow your convention, e.g. nic-<workload>-<env>-<nn>."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
    error_message = "name must be 2-80 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 NIC is created in."
  type        = string
}

variable "location" {
  description = "Azure region for the NIC (must match the subnet's VNet region)."
  type        = string
}

variable "ip_configurations" {
  description = <<-EOT
    List of IP configurations for the NIC. Each entry:
      name                          - IP config name; referenced by LB associations (required)
      subnet_id                     - subnet the NIC lands in (required)
      private_ip_address_allocation - "Dynamic" (default) or "Static"
      private_ip_address            - required when allocation is "Static"
      public_ip_address_id          - optional public IP to associate
      primary                       - optional; first config is primary by default
      backend_address_pool_ids      - optional LB backend pool IDs to join (default [])
  EOT
  type = list(object({
    name                          = string
    subnet_id                     = string
    private_ip_address_allocation = optional(string, "Dynamic")
    private_ip_address            = optional(string)
    public_ip_address_id          = optional(string)
    primary                       = optional(bool)
    backend_address_pool_ids      = optional(list(string), [])
  }))

  validation {
    condition     = length(var.ip_configurations) > 0
    error_message = "At least one ip_configuration is required."
  }

  validation {
    condition = alltrue([
      for c in var.ip_configurations :
      contains(["Dynamic", "Static"], c.private_ip_address_allocation)
    ])
    error_message = "private_ip_address_allocation must be Dynamic or Static."
  }

  validation {
    condition = alltrue([
      for c in var.ip_configurations :
      c.private_ip_address_allocation != "Static" || c.private_ip_address != null
    ])
    error_message = "private_ip_address is required whenever private_ip_address_allocation is Static."
  }

  validation {
    condition = length([
      for c in var.ip_configurations : c if coalesce(c.primary, false)
    ]) <= 1
    error_message = "At most one ip_configuration may be marked primary."
  }
}

variable "network_security_group_id" {
  description = "Optional NSG resource ID to associate directly with the NIC. null = no NIC-level NSG."
  type        = string
  default     = null
}

variable "application_security_group_ids" {
  description = "List of Application Security Group resource IDs the NIC should join."
  type        = list(string)
  default     = []
}

variable "accelerated_networking_enabled" {
  description = "Enable Accelerated Networking (SR-IOV). Only supported on certain VM sizes."
  type        = bool
  default     = false
}

variable "ip_forwarding_enabled" {
  description = "Enable IP forwarding. Required for NVAs/firewalls that route transit traffic."
  type        = bool
  default     = false
}

variable "dns_servers" {
  description = "Custom DNS server IPs for the NIC. Empty = inherit the VNet's DNS settings."
  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 "tags" {
  description = "Tags merged with module defaults and applied to the NIC."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Network interface resource ID — feed into a VM's network_interface_ids."
  value       = azurerm_network_interface.this.id
}

output "name" {
  description = "Network interface name."
  value       = azurerm_network_interface.this.name
}

output "private_ip_address" {
  description = "Primary private IP address of the NIC."
  value       = azurerm_network_interface.this.private_ip_address
}

output "private_ip_addresses" {
  description = "All private IP addresses assigned to the NIC."
  value       = azurerm_network_interface.this.private_ip_addresses
}

output "mac_address" {
  description = "MAC address of the NIC (populated once attached to a running VM)."
  value       = azurerm_network_interface.this.mac_address
}

output "nsg_association_id" {
  description = "ID of the NIC-NSG association, or null when no NSG was attached."
  value       = try(azurerm_network_interface_security_group_association.this[0].id, null)
}

output "application_security_group_association_ids" {
  description = "Map of ASG ID => association resource ID."
  value       = { for k, a in azurerm_network_interface_application_security_group_association.this : k => a.id }
}

output "backend_address_pool_association_ids" {
  description = "Map of '<ipconfig>|<poolId>' => backend pool association resource ID."
  value       = { for k, a in azurerm_network_interface_backend_address_pool_association.this : k => a.id }
}

How to use it

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

  name                = "nic-web-prod-01"
  resource_group_name = module.rg.name
  location            = module.rg.location

  accelerated_networking_enabled = true

  ip_configurations = [{
    name                          = "ipconfig1"
    subnet_id                     = module.virtual_network.subnet_ids["snet-web"]
    private_ip_address_allocation = "Static"
    private_ip_address            = "10.20.1.10"

    # Register this IP config into the web tier's load balancer backend pool.
    backend_address_pool_ids = [azurerm_lb_backend_address_pool.web.id]
  }]

  # NIC-level NSG and ASG membership for micro-segmentation.
  network_security_group_id      = azurerm_network_security_group.web.id
  application_security_group_ids = [azurerm_application_security_group.web.id]

  tags = {
    env      = "prod"
    workload = "storefront"
    tier     = "web"
  }
}

# Downstream: attach the NIC to a VM via network_interface_ids (NOT inline).
resource "azurerm_linux_virtual_machine" "web" {
  name                = "vm-web-prod-01"
  resource_group_name = module.rg.name
  location            = module.rg.location
  size                = "Standard_D4s_v5"
  admin_username      = "azureadmin"

  network_interface_ids = [module.nic.id]

  admin_ssh_key {
    username   = "azureadmin"
    public_key = file("~/.ssh/id_rsa.pub")
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-24_04-lts"
    sku       = "server"
    version   = "latest"
  }
}

Pin the module with ?ref=<tag> so a stack never silently picks up a breaking change. Changing a NIC’s subnet_id or a Static IP forces the NIC — and any VM attached to it — to be recreated.

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/nic_web/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-interface?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  ip_configurations = [{
    name = "ipconfig1"
    subnet_id = "..."
  }]
}

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

cd live/prod/nic_web && 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 NIC name (validated, 2–80 chars).
resource_group_name string Yes Resource group for the NIC.
location string Yes Azure region (must match the subnet’s VNet).
ip_configurations list(object) Yes IP configs (name, subnet, allocation, optional static IP/public IP/primary/backend pools).
network_security_group_id string null No NSG ID to associate directly with the NIC.
application_security_group_ids list(string) [] No ASG IDs the NIC should join.
accelerated_networking_enabled bool false No Enable SR-IOV accelerated networking.
ip_forwarding_enabled bool false No Enable IP forwarding for NVAs/firewalls.
dns_servers list(string) [] No NIC-level DNS overrides; empty inherits the VNet.
tags map(string) {} No Tags merged with module defaults.

Outputs

Name Description
id NIC resource ID — feed into a VM’s network_interface_ids.
name NIC name.
private_ip_address Primary private IP address.
private_ip_addresses All private IPs assigned to the NIC.
mac_address MAC address (populated once attached to a running VM).
nsg_association_id NIC-NSG association ID, or null when none.
application_security_group_association_ids Map of ASG ID → association ID.
backend_address_pool_association_ids Map of `<ipconfig>

Enterprise scenario

A retail platform runs a three-tier storefront — web, app, and data VMs behind an internal load balancer — across dev, staging, and prod. The platform team publishes this module at v1.0.0 so every VM’s NIC is built identically: a single IP config on the tier’s subnet, accelerated networking on for the Dsv5 sizes, the tier’s NSG bound at the NIC level, and ASG membership (asg-web, asg-app, asg-data) so segmentation rules read as “web may reach app on 8080” instead of fragile IP lists. Web-tier NICs join the load balancer backend pool through the module’s backend_address_pool_ids, so scaling the tier is just another module call. Because the NIC lifecycle is decoupled from the VM, the SRE team can re-image a VM, swap its size, or rebuild it while the NIC — and its stable private IP and pool membership — stays put.

Best practices

TerraformAzureNetwork InterfaceModuleIaC
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