Quick take — A reusable hashicorp/azurerm ~> 4.0 Terraform module for Azure Application Security Groups (ASGs): create named ASGs, attach NICs by config, and reference them in NSG rules for IP-free microsegmentation. 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 "application_security_group" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-application-security-group?ref=v1.0.0"
resource_group_name = "..." # Resource group that hosts the ASGs. Must be non-empty.
location = "..." # Default Azure region for the ASGs; overridable per ASG.
application_security_groups = {} # Map of ASGs to create, keyed by a stable logical name. …
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Application Security Group (ASG) is a logical grouping of network interfaces (NICs) that lets you write Network Security Group (NSG) rules against an application role — asg-web, asg-app, asg-db — instead of against IP addresses or CIDR ranges. You attach VMs/NICs to an ASG, then your NSG rules say “allow 1433 from asg-app to asg-db” and Azure resolves the membership for you at evaluation time. When a new database VM scales out and joins asg-db, every rule that references it applies automatically — no rule edits, no IP bookkeeping, no /32 sprawl.
The raw resource — azurerm_application_security_group — is deceptively tiny: it has essentially name, location, resource_group_name, and tags. There is no membership block on the ASG itself; membership lives on the consumer (azurerm_network_interface_application_security_group_association for a standalone NIC, or the application_security_group_ids field inside a VMSS / NIC IP configuration). That split is exactly why a thin module is worth it: it standardises naming, tags, and location across every ASG in a landing zone, and — optionally — wires up the NIC associations in the same call so consumers do not have to remember the separate association resource. Wrapping it also gives you a single, validated contract (for_each-friendly map of ASGs) that fits cleanly into a hub-and-spoke or per-workload module composition.
When to use it
- You are building microsegmentation inside a subnet (or across subnets that share NSGs) and want rules keyed to application tiers, not IPs.
- You run autoscaling workloads (VMSS or manually scaled VMs) where instance IPs churn and IP-based rules would constantly drift.
- You want a landing-zone-consistent way to stamp out the standard
web / app / dataASG trio per spoke, with enforced naming and tagging. - You need ASGs created before the NSG module that references them, so you can pass their IDs as inputs (Terraform’s dependency graph handles the ordering when you output the IDs).
- Skip ASGs (and this module) if your segmentation is purely subnet-to-subnet — plain NSG rules with subnet prefixes are simpler. ASGs shine when multiple roles share a subnet, or when membership is dynamic.
Module structure
terraform-module-azure-application-security-group/
├── versions.tf # provider + Terraform version pins
├── main.tf # azurerm_application_security_group + optional NIC associations
├── variables.tf # var-driven inputs with validation
└── outputs.tf # ids / names maps + association ids
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Flatten every (asg_key -> list of nic_ids) into individual association
# objects so each NIC<->ASG link is its own resource instance.
nic_associations = merge([
for asg_key, asg in var.application_security_groups : {
for nic_id in asg.network_interface_ids :
"${asg_key}|${substr(md5(nic_id), 0, 8)}" => {
asg_key = asg_key
nic_id = nic_id
}
}
]...)
}
resource "azurerm_application_security_group" "this" {
for_each = var.application_security_groups
name = each.value.name
resource_group_name = var.resource_group_name
location = coalesce(each.value.location, var.location)
tags = merge(var.tags, each.value.tags)
}
# Optional: associate existing standalone NICs to the ASGs created above.
# VMSS / NICs that you build elsewhere can instead consume the output IDs.
resource "azurerm_network_interface_application_security_group_association" "this" {
for_each = local.nic_associations
network_interface_id = each.value.nic_id
application_security_group_id = azurerm_application_security_group.this[each.value.asg_key].id
}
variables.tf
variable "resource_group_name" {
description = "Name of the resource group that hosts the Application Security Groups."
type = string
validation {
condition = length(var.resource_group_name) > 0
error_message = "resource_group_name must not be empty."
}
}
variable "location" {
description = "Default Azure region for the ASGs. Can be overridden per-ASG."
type = string
}
variable "application_security_groups" {
description = <<-EOT
Map of Application Security Groups to create. The map key is a stable
logical name (e.g. "web", "app", "db") used for for_each addressing and
in outputs. Each value defines the real Azure ASG name, optional region
override, optional tags, and an optional list of existing NIC IDs to
associate with the ASG.
EOT
type = map(object({
name = string
location = optional(string)
tags = optional(map(string), {})
network_interface_ids = optional(list(string), [])
}))
validation {
condition = length(var.application_security_groups) > 0
error_message = "Provide at least one Application Security Group."
}
validation {
condition = alltrue([
for k, v in var.application_security_groups :
can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", v.name)) || length(v.name) == 1
])
error_message = "Each ASG name must be 1-80 chars, start with a letter/number, and contain only letters, numbers, '.', '_' or '-'."
}
validation {
condition = length(distinct([
for k, v in var.application_security_groups : lower(v.name)
])) == length(var.application_security_groups)
error_message = "ASG 'name' values must be unique within the resource group (case-insensitive)."
}
}
variable "tags" {
description = "Tags applied to every ASG. Merged with (and overridden by) per-ASG tags."
type = map(string)
default = {}
}
outputs.tf
output "ids" {
description = "Map of logical key => ASG resource ID. Feed these into NSG rule source/destination_application_security_group_ids."
value = { for k, asg in azurerm_application_security_group.this : k => asg.id }
}
output "names" {
description = "Map of logical key => actual Azure ASG name."
value = { for k, asg in azurerm_application_security_group.this : k => asg.name }
}
output "application_security_groups" {
description = "Full map of logical key => { id, name } for downstream composition."
value = {
for k, asg in azurerm_application_security_group.this : k => {
id = asg.id
name = asg.name
}
}
}
output "association_ids" {
description = "Map of NIC-association key => association resource ID (only for NICs associated by this module)."
value = { for k, a in azurerm_network_interface_application_security_group_association.this : k => a.id }
}
How to use it
module "application_security_group" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-application-security-group?ref=v1.0.0"
resource_group_name = azurerm_resource_group.spoke.name
location = azurerm_resource_group.spoke.location
tags = {
environment = "prod"
workload = "orders-api"
managed_by = "terraform"
}
application_security_groups = {
web = {
name = "asg-orders-web-prod"
}
app = {
name = "asg-orders-app-prod"
}
db = {
name = "asg-orders-db-prod"
# Associate two already-provisioned database NICs to this ASG.
network_interface_ids = [
azurerm_network_interface.db01.id,
azurerm_network_interface.db02.id,
]
}
}
}
# Downstream: an NSG rule that uses ASG IDs instead of IPs.
# "Allow SQL from the app tier to the db tier, nothing else."
resource "azurerm_network_security_rule" "app_to_db_sql" {
name = "allow-app-to-db-1433"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "1433"
resource_group_name = azurerm_resource_group.spoke.name
network_security_group_name = azurerm_network_security_group.data_subnet.name
source_application_security_group_ids = [module.application_security_group.ids["app"]]
destination_application_security_group_ids = [module.application_security_group.ids["db"]]
}
The new VM/VMSS NICs for the app tier just set application_security_group_ids = [module.application_security_group.ids["app"]] in their IP configuration, and the rule above starts protecting them immediately.
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/application_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-application-security-group?ref=v1.0.0"
}
inputs = {
resource_group_name = "..."
location = "..."
application_security_groups = {}
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/application_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 |
|---|---|---|---|---|
resource_group_name |
string |
— | Yes | Resource group that hosts the ASGs. Must be non-empty. |
location |
string |
— | Yes | Default Azure region for the ASGs; overridable per ASG. |
application_security_groups |
map(object({ name = string, location = optional(string), tags = optional(map(string), {}), network_interface_ids = optional(list(string), []) })) |
— | Yes | Map of ASGs to create, keyed by a stable logical name. Validated for ≥1 entry, Azure-legal names, and case-insensitive name uniqueness. |
tags |
map(string) |
{} |
No | Tags applied to every ASG; merged with and overridden by per-ASG tags. |
Outputs
| Name | Description |
|---|---|
ids |
Map of logical key => ASG resource ID. Pass into NSG source_/destination_application_security_group_ids. |
names |
Map of logical key => actual Azure ASG name. |
application_security_groups |
Map of logical key => { id, name } object for downstream composition. |
association_ids |
Map of NIC-association key => association resource ID, for NICs associated by this module. |
Enterprise scenario
A retail platform team runs a three-tier orders-api workload where web, app, and SQL VMs all share a single /24 application subnet per spoke. Rather than maintain dozens of IP-based NSG rules that break every time the app tier autoscales, they stamp the web / app / db ASG trio with this module in each of their 14 spokes via a single for_each over the spoke map, then write exactly three NSG rules per spoke (internet→web:443, web→app:8080, app→db:1433). When the app VMSS scales from 4 to 40 instances during a sale, the new NICs inherit asg-orders-app-prod membership and are immediately covered — the security team makes zero rule changes, and the consistent ASG naming makes the Azure Firewall and traffic-analytics dashboards instantly readable.
Best practices
- Reference ASGs by ID, never by IP — the entire point is to decouple rules from addressing. In NSG rules use
source_application_security_group_ids/destination_application_security_group_idsfed from this module’sidsoutput; mixingsource_address_prefixand ASGs on the same rule is disallowed by Azure. - Keep ASG and consumer NICs in the same region — an NIC can only join an ASG in its own location, and the ASG and the NSG that references it must share a region. Use the per-ASG
locationoverride sparingly and only when you genuinely deploy multi-region from one module call. - Name for the role, not the resource —
asg-orders-app-prod, notasg-vm-nic-3. ASGs are nearly free (no compute, no standalone billing) but they are read constantly in dashboards and rules, so a consistentasg-<workload>-<tier>-<env>convention pays off; enforce it through thenamevalidation. - Mind the per-region ASG limit — Azure caps ASGs per subscription per region (commonly 3000) and an NSG flow can reference a bounded number of ASGs; design tiers, not per-VM ASGs, to stay well under quota.
- Let Terraform order creation, not
depends_on— because the NSG module consumes theidsoutput, the graph already creates ASGs first; avoid manualdepends_onthat would over-serialise the apply. - All NICs in a rule’s ASG must live in the same VNet — Azure requires every NIC in the source ASG and every NIC in the destination ASG of a rule to be in the same virtual network, so scope each ASG to one VNet/spoke and create per-spoke instances rather than sharing one ASG across peered VNets.