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_idsargument — 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:
azurerm_network_interface_security_group_association— binds an NSG to the NIC. (Azure also supports subnet-level NSGs; NIC-level is the finer-grained option.)azurerm_network_interface_application_security_group_association— adds the NIC to an Application Security Group (ASG), so NSG rules can target a logical group likeasg-webinstead of brittle IP lists.azurerm_network_interface_backend_address_pool_association— registers one of the NIC’s IP configurations into a load balancer backend pool.
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
- You provision standalone VMs and want a consistent, reviewed NIC definition rather than copy-pasting
ip_configurationblocks with inconsistent allocation methods and forgotten NSG wiring. - You run workloads behind an Azure Load Balancer and need NICs joined to backend pools as a repeatable, declarative step.
- You use Application Security Groups for micro-segmentation and want NICs joined to the right ASGs (
asg-web,asg-app,asg-data) so NSG rules stay readable. - You deploy network virtual appliances / firewalls that need
ip_forwarding_enabled = true, or latency-sensitive workloads that needaccelerated_networking_enabled = true. - You want NIC-level custom DNS applied consistently and the lifecycle of the NIC decoupled from the VM it serves.
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’ssubnet_idor 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 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/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
- Attach the NIC to the VM, never inline. Keep the NIC as its own resource and pass
module.nic.idto the VM’snetwork_interface_ids. This decouples lifecycles so you can rebuild compute without losing the NIC’s IP, NSG, and pool membership. - Use Static IPs only when something depends on the address. Default to
Dynamicfor most workloads; reserveStaticfor DNS-pinned services, NVAs, and cluster nodes — and the module validates that a Static config actually supplies an address. - Prefer ASGs over IP-based NSG rules. Join NICs to Application Security Groups and write NSG rules against the groups; this keeps segmentation readable and survives IP changes as the fleet scales.
- Enable accelerated networking on supported sizes. It offloads packet processing to hardware (SR-IOV) for higher throughput and lower latency at no extra cost — but it is only valid on specific VM sizes, so gate it per workload.
- Turn on IP forwarding only for appliances.
ip_forwarding_enabledis required for firewalls/NVAs that route transit traffic and should stayfalsefor ordinary VMs to avoid accidental routing behaviour. - Decide NSG scope deliberately. NIC-level NSGs give per-VM control; subnet-level NSGs give blanket coverage. Pick one primary layer per tier to avoid confusing overlapping rule evaluation, and name NICs by a
nic-<workload>-<env>-<nn>convention enforced by thenamevalidation.