IaC Azure

Terraform Module: Azure Application Security Group — name-based microsegmentation without IP juggling

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 roleasg-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

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 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/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

TerraformAzureApplication 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