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
- You manage many NSGs across landing zones (hub, spoke, app tiers) and want one audited rule shape instead of copy-pasted
security_ruleblocks. - You want rules expressed as data (a
for_each-friendly list) so they can come from a YAML/JSON catalogue or a CSV of approved flows. - You need Application Security Group (ASG) based micro-segmentation (rule by workload role, not by IP) and want the association handled for you.
- You want the subnet or NIC association managed in the same module so drift between “the NSG exists” and “the NSG is actually attached” is impossible.
- You do not need this for App Service / PaaS data-plane filtering (use service firewalls / Private Endpoints there) — NSGs only govern traffic to IaaS NICs and subnet-delegated resources.
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 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/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
- End every direction with an explicit
Deny-Allat priority 4096. The platform default-deny sits below your custom rules, so an explicit deny makes intent auditable and protects against a future over-broad allow rule slipping in above it. - Filter by Application Security Group, not IP, for workload-to-workload rules. ASGs survive IP churn from autoscale and re-IP events; rules read as “web → app”, which is reviewable, and you avoid maintaining brittle address-prefix lists.
- Prefer subnet-level association over per-NIC. One NSG on a subnet covers every current and future NIC in it, eliminates the “new VM shipped without an NSG” gap, and keeps rule counts (and cost-of-review) down — reserve NIC association for genuine exceptions.
- Use service tags (
Internet,Storage,AzureKeyVault,GatewayManager) instead of hard-coded CIDRs. Azure maintains the address ranges, so your rules stay correct as Microsoft adds endpoints, and they read as intent rather than magic numbers. - Leave space between priorities (100, 110, 120…). Gaps let you insert a future rule between two existing ones without renumbering the whole set and re-triggering a noisy plan.
- Always pair NSGs with flow logs and Traffic Analytics. NSGs are silent by default; routing the
idoutput into anetwork_watcher_flow_loggives you the deny/allow evidence you need for incident response and for proving least-privilege to auditors.