IaC Azure

Terraform Module: Azure Network Security Group — declarative, ASG-aware firewall rules with safe defaults

Quick take — Reusable hashicorp/azurerm ~> 4.0 module for azurerm_network_security_group: var-driven inbound/outbound rules, Application Security Group support, subnet/NIC association, and a default-deny posture. 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_security_group" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-network-security-group?ref=v1.0.0"

  name                = "..."  # Name of the NSG (validated: 2-80 chars, allowed charact…
  location            = "..."  # Azure region for the NSG (e.g. `centralindia`).
  resource_group_name = "..."  # Resource group that will contain the NSG.
}

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

What this module is

An Azure Network Security Group (NSG) is a stateful, layer-3/layer-4 packet filter that holds a prioritised list of allow/deny rules evaluated against the 5-tuple (source, source port, destination, destination port, protocol). You attach an NSG to a subnet or directly to a NIC, and Azure applies its rules to traffic crossing that boundary — on top of the platform’s built-in default rules (which already allow intra-VNet and load-balancer probe traffic and deny everything else inbound).

In raw Terraform, NSGs are deceptively fiddly: rule priority values must be unique and ordered, you have to choose between the singular vs plural address/port fields (source_address_prefix vs source_address_prefixes), Application Security Groups need their IDs threaded in, and the subnet/NIC association is a separate resource that silently fights with portal edits. This module wraps azurerm_network_security_group so callers describe rules as a simple list of objects, get input validation on protocols/priorities, and optionally bind the NSG to a subnet or NIC — all with a consistent naming and tagging contract across every team.

When to use it

Module structure

terraform-module-azure-network-security-group/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # NSG, rules, ASG-aware wiring, subnet/NIC association
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # id / name + rule + association outputs

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

resource "azurerm_network_security_group" "this" {
  name                = var.name
  location            = var.location
  resource_group_name = var.resource_group_name
  tags                = var.tags
}

# Rules are managed as standalone resources (not inline security_rule blocks)
# so a single rule change does not force-replace the whole rule set, and so
# rules can be driven by a for_each over a list of objects.
resource "azurerm_network_security_rule" "this" {
  for_each = { for rule in var.security_rules : rule.name => rule }

  name                        = each.value.name
  resource_group_name         = var.resource_group_name
  network_security_group_name = azurerm_network_security_group.this.name

  priority  = each.value.priority
  direction = each.value.direction
  access    = each.value.access
  protocol  = each.value.protocol

  description = each.value.description

  # Singular vs plural: prefer the plural *_prefixes/*_ranges; fall back to the
  # singular form. Exactly one side of each pair must be null for azurerm.
  source_port_range       = each.value.source_port_ranges == null ? coalesce(each.value.source_port_range, "*") : null
  source_port_ranges      = each.value.source_port_ranges
  destination_port_range  = each.value.destination_port_ranges == null ? each.value.destination_port_range : null
  destination_port_ranges = each.value.destination_port_ranges

  source_address_prefix        = each.value.source_application_security_group_ids == null && each.value.source_address_prefixes == null ? each.value.source_address_prefix : null
  source_address_prefixes      = each.value.source_application_security_group_ids == null ? each.value.source_address_prefixes : null
  destination_address_prefix   = each.value.destination_application_security_group_ids == null && each.value.destination_address_prefixes == null ? each.value.destination_address_prefix : null
  destination_address_prefixes = each.value.destination_application_security_group_ids == null ? each.value.destination_address_prefixes : null

  source_application_security_group_ids      = each.value.source_application_security_group_ids
  destination_application_security_group_ids = each.value.destination_application_security_group_ids
}

# Optional: associate with a subnet (one NSG can cover many resources at once).
resource "azurerm_subnet_network_security_group_association" "this" {
  count = var.subnet_id != null ? 1 : 0

  subnet_id                 = var.subnet_id
  network_security_group_id = azurerm_network_security_group.this.id
}

# Optional: associate directly with a NIC (finer-grained than subnet).
resource "azurerm_network_interface_security_group_association" "this" {
  count = var.network_interface_id != null ? 1 : 0

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

variables.tf

variable "name" {
  description = "Name of the Network Security Group."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
    error_message = "NSG name must be 2-80 chars, start alphanumeric, and contain only letters, numbers, '.', '_' or '-'."
  }
}

variable "location" {
  description = "Azure region for the NSG (e.g. centralindia)."
  type        = string
}

variable "resource_group_name" {
  description = "Resource group that will contain the NSG."
  type        = string
}

variable "subnet_id" {
  description = "Optional subnet ID to associate the NSG with. Mutually relevant with network_interface_id; usually associate at the subnet level."
  type        = string
  default     = null
}

variable "network_interface_id" {
  description = "Optional NIC ID to associate the NSG with directly."
  type        = string
  default     = null
}

variable "security_rules" {
  description = "List of custom security rules. Each rule must use either the *_prefix or *_prefixes form (not both); set the ASG id list to scope a rule to an Application Security Group."
  type = list(object({
    name                                       = string
    priority                                   = number
    direction                                  = string
    access                                     = string
    protocol                                   = string
    description                                = optional(string, "")
    source_port_range                          = optional(string)
    source_port_ranges                         = optional(list(string))
    destination_port_range                     = optional(string)
    destination_port_ranges                    = optional(list(string))
    source_address_prefix                      = optional(string)
    source_address_prefixes                    = optional(list(string))
    destination_address_prefix                 = optional(string)
    destination_address_prefixes               = optional(list(string))
    source_application_security_group_ids      = optional(list(string))
    destination_application_security_group_ids = optional(list(string))
  }))
  default = []

  validation {
    condition = alltrue([
      for r in var.security_rules : r.priority >= 100 && r.priority <= 4096
    ])
    error_message = "Each rule priority must be between 100 and 4096."
  }

  validation {
    condition = length(distinct([
      for r in var.security_rules : "${lower(r.direction)}:${r.priority}"
    ])) == length(var.security_rules)
    error_message = "Rule priorities must be unique within the same direction (Inbound/Outbound)."
  }

  validation {
    condition = alltrue([
      for r in var.security_rules : contains(["Inbound", "Outbound"], r.direction)
    ])
    error_message = "direction must be 'Inbound' or 'Outbound'."
  }

  validation {
    condition = alltrue([
      for r in var.security_rules : contains(["Allow", "Deny"], r.access)
    ])
    error_message = "access must be 'Allow' or 'Deny'."
  }

  validation {
    condition = alltrue([
      for r in var.security_rules : contains(["Tcp", "Udp", "Icmp", "Esp", "Ah", "*"], r.protocol)
    ])
    error_message = "protocol must be one of Tcp, Udp, Icmp, Esp, Ah or '*'."
  }
}

variable "tags" {
  description = "Tags applied to the NSG."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Resource ID of the Network Security Group."
  value       = azurerm_network_security_group.this.id
}

output "name" {
  description = "Name of the Network Security Group."
  value       = azurerm_network_security_group.this.name
}

output "location" {
  description = "Region the NSG is deployed in."
  value       = azurerm_network_security_group.this.location
}

output "security_rule_ids" {
  description = "Map of rule name => security rule resource ID."
  value       = { for k, r in azurerm_network_security_rule.this : k => r.id }
}

output "subnet_association_id" {
  description = "ID of the subnet association, if a subnet was attached (else null)."
  value       = try(azurerm_subnet_network_security_group_association.this[0].id, null)
}

How to use it

# An Application Security Group that tags the web tier VMs (created elsewhere).
resource "azurerm_application_security_group" "web" {
  name                = "asg-web-prod"
  location            = "centralindia"
  resource_group_name = "rg-network-prod"
}

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

  name                = "nsg-web-prod"
  location            = "centralindia"
  resource_group_name = "rg-network-prod"
  subnet_id           = azurerm_subnet.web.id

  security_rules = [
    {
      name                                  = "Allow-HTTPS-Inbound"
      priority                              = 100
      direction                             = "Inbound"
      access                                = "Allow"
      protocol                              = "Tcp"
      description                           = "Public HTTPS to web tier"
      source_address_prefix                 = "Internet"
      destination_port_range                = "443"
      destination_application_security_group_ids = [azurerm_application_security_group.web.id]
    },
    {
      name                    = "Allow-AppGw-HealthProbe"
      priority                = 110
      direction               = "Inbound"
      access                  = "Allow"
      protocol                = "Tcp"
      source_address_prefix   = "GatewayManager"
      destination_port_ranges = ["65200", "65535"]
    },
    {
      name                       = "Deny-All-Inbound"
      priority                   = 4096
      direction                  = "Inbound"
      access                     = "Deny"
      protocol                   = "*"
      source_address_prefix      = "*"
      destination_address_prefix = "*"
      destination_port_range     = "*"
    }
  ]

  tags = {
    environment = "prod"
    tier        = "web"
    owner       = "platform-team"
  }
}

# Downstream reference: feed the NSG id into a Network Watcher flow log so the
# security team gets per-rule traffic telemetry for this exact NSG.
resource "azurerm_network_watcher_flow_log" "web" {
  name                      = "fl-nsg-web-prod"
  network_watcher_name      = "nw-centralindia"
  resource_group_name       = "rg-network-prod"
  network_security_group_id = module.network_security_group.id
  storage_account_id        = azurerm_storage_account.flowlogs.id
  enabled                   = true

  retention_policy {
    enabled = true
    days    = 30
  }
}

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/network_security_group/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-security-group?ref=v1.0.0"
}

inputs = {
  name = "..."
  location = "..."
  resource_group_name = "..."
}

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

cd live/prod/network_security_group && 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 NSG (validated: 2-80 chars, allowed character set).
location string Yes Azure region for the NSG (e.g. centralindia).
resource_group_name string Yes Resource group that will contain the NSG.
subnet_id string null No Subnet to associate the NSG with. If set, a subnet association is created.
network_interface_id string null No NIC to associate the NSG with directly. If set, a NIC association is created.
security_rules list(object) [] No Custom rules; each uses either the singular *_prefix/*_range or plural *_prefixes/*_ranges form, or an ASG id list. Validated for unique per-direction priority, valid access/direction/protocol.
tags map(string) {} No Tags applied to the NSG.

Outputs

Name Description
id Resource ID of the Network Security Group.
name Name of the Network Security Group.
location Region the NSG is deployed in.
security_rule_ids Map of rule name => security rule resource ID.
subnet_association_id ID of the subnet association, if a subnet was attached (else null).

Enterprise scenario

A retail platform runs a three-tier app (web / app / data) across spoke VNets in centralindia, with each tier fronted by an Application Security Group. The platform team publishes a YAML catalogue of approved east-west flows and feeds it into this module via for_each, so the “app tier may reach the data tier on 5432, nothing else can” rule is enforced identically in dev, UAT, and prod from one source of truth. Because the module owns both the rules and the subnet associations, an auditor can confirm from the Terraform state — not the portal — that every subnet has its intended NSG attached, and the id output wires straight into NSG flow logs for the SOC.

Best practices

TerraformAzureNetwork Security GroupModuleIaC
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