IaC Azure

Terraform Module: Azure Load Balancer — Standard L4 distribution with health-probed backend pools

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for Azure Standard Load Balancer: frontend IP configs, backend pools, health probes, and LB rules with HA ports and outbound SNAT control. 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 "load_balancer" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-load-balancer?ref=v1.0.0"

  name                       = "..."           # Name of the Load Balancer (1-80 chars, validated).
  resource_group_name        = "..."           # Resource group for the Load Balancer.
  location                   = "..."           # Azure region (e.g. `centralindia`).
  frontend_ip_configurations = ["...", "..."]  # Frontends; each sets exactly one of `public_ip_address_…
}

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

What this module is

Azure Load Balancer is a Layer-4 (TCP/UDP) load distribution service that spreads inbound and outbound flows across a backend pool of VMs or VM Scale Set instances. Unlike Application Gateway or Front Door, it does not inspect HTTP — it hashes a 5-tuple (source IP, source port, destination IP, destination port, protocol) and pins each flow to a healthy backend. The Standard SKU is the production tier: it is zone-redundant, supports up to 1000 backend instances, exposes HA Ports (load-balance all ports at once for NVA scenarios), and is secure-by-default (closed unless an NSG explicitly allows traffic).

Wiring a Load Balancer by hand is deceptively fiddly. A working setup is never just the azurerm_lb resource — you also need at least one frontend IP configuration, a backend address pool, a health probe, and one or more load-balancing rules that stitch those three together by ID. Get the probe protocol or rule’s disable_outbound_snat wrong and you either blackhole traffic or silently break outbound internet access for the whole pool. This module collapses that ceremony into a single, var-driven call: you describe your frontends, probes, and rules as data, and it returns the IDs every downstream resource (NIC associations, NAT rules, autoscale settings) needs to attach to.

When to use it

Reach for Application Gateway or Front Door instead when you need TLS termination, path/host routing, WAF, or cookie affinity — those are Layer-7 concerns this module deliberately does not handle.

Module structure

terraform-module-azure-load-balancer/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_lb + frontend/pool/probe/rule resources
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # ids, frontend IP, pool/probe/rule maps

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # A frontend is "public" if it carries a public IP id, otherwise "internal".
  is_internal = length([
    for f in var.frontend_ip_configurations : f
    if f.public_ip_address_id == null
  ]) == length(var.frontend_ip_configurations)
}

resource "azurerm_lb" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location
  sku                 = var.sku
  sku_tier            = var.sku_tier
  tags                = var.tags

  dynamic "frontend_ip_configuration" {
    for_each = var.frontend_ip_configurations
    content {
      name                          = frontend_ip_configuration.value.name
      public_ip_address_id          = frontend_ip_configuration.value.public_ip_address_id
      subnet_id                     = frontend_ip_configuration.value.subnet_id
      private_ip_address            = frontend_ip_configuration.value.private_ip_address
      private_ip_address_allocation = frontend_ip_configuration.value.private_ip_address != null ? "Static" : (frontend_ip_configuration.value.subnet_id != null ? "Dynamic" : null)
      private_ip_address_version    = frontend_ip_configuration.value.private_ip_address_version
      zones                         = frontend_ip_configuration.value.zones
    }
  }
}

resource "azurerm_lb_backend_address_pool" "this" {
  for_each = var.backend_pools

  name            = each.key
  loadbalancer_id = azurerm_lb.this.id
}

resource "azurerm_lb_probe" "this" {
  for_each = var.health_probes

  name                = each.key
  loadbalancer_id     = azurerm_lb.this.id
  protocol            = each.value.protocol
  port                = each.value.port
  request_path        = each.value.protocol == "Http" || each.value.protocol == "Https" ? each.value.request_path : null
  interval_in_seconds = each.value.interval_in_seconds
  number_of_probes    = each.value.number_of_probes
  probe_threshold     = each.value.probe_threshold
}

resource "azurerm_lb_rule" "this" {
  for_each = var.lb_rules

  name                           = each.key
  loadbalancer_id                = azurerm_lb.this.id
  frontend_ip_configuration_name = each.value.frontend_ip_configuration_name
  protocol                       = each.value.protocol
  frontend_port                  = each.value.enable_ha_ports ? 0 : each.value.frontend_port
  backend_port                   = each.value.enable_ha_ports ? 0 : each.value.backend_port
  enable_floating_ip             = each.value.enable_floating_ip
  enable_tcp_reset               = each.value.enable_tcp_reset
  disable_outbound_snat          = each.value.disable_outbound_snat
  idle_timeout_in_minutes        = each.value.idle_timeout_in_minutes
  load_distribution              = each.value.load_distribution

  backend_address_pool_ids = [
    for pool_name in each.value.backend_pool_names :
    azurerm_lb_backend_address_pool.this[pool_name].id
  ]

  probe_id = each.value.probe_name != null ? azurerm_lb_probe.this[each.value.probe_name].id : null
}

variables.tf

variable "name" {
  type        = string
  description = "Name of the Azure Load Balancer."

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
    error_message = "Load Balancer name must be 1-80 chars and start with a letter or number."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group in which to create the Load Balancer."
}

variable "location" {
  type        = string
  description = "Azure region for the Load Balancer (e.g. centralindia, eastus)."
}

variable "sku" {
  type        = string
  description = "Load Balancer SKU. Standard is required for zones, HA ports and large pools."
  default     = "Standard"

  validation {
    condition     = contains(["Basic", "Standard", "Gateway"], var.sku)
    error_message = "sku must be one of: Basic, Standard, Gateway."
  }
}

variable "sku_tier" {
  type        = string
  description = "SKU tier: Regional or Global (Global enables cross-region LB)."
  default     = "Regional"

  validation {
    condition     = contains(["Regional", "Global"], var.sku_tier)
    error_message = "sku_tier must be Regional or Global."
  }
}

variable "frontend_ip_configurations" {
  description = <<-EOT
    Frontend IP configurations. Set public_ip_address_id for an internet-facing
    frontend, OR subnet_id (+ optional static private_ip_address) for an internal one.
    Exactly one of the two must be provided per frontend.
  EOT
  type = list(object({
    name                       = string
    public_ip_address_id       = optional(string)
    subnet_id                  = optional(string)
    private_ip_address         = optional(string)
    private_ip_address_version = optional(string, "IPv4")
    zones                      = optional(list(string))
  }))

  validation {
    condition     = length(var.frontend_ip_configurations) > 0
    error_message = "At least one frontend_ip_configuration is required."
  }

  validation {
    condition = alltrue([
      for f in var.frontend_ip_configurations :
      (f.public_ip_address_id != null) != (f.subnet_id != null)
    ])
    error_message = "Each frontend must set exactly one of public_ip_address_id or subnet_id."
  }
}

variable "backend_pools" {
  type        = map(object({}))
  description = "Set of backend address pools, keyed by pool name. Members are attached downstream via NIC/VMSS associations."
  default     = {}
}

variable "health_probes" {
  description = "Health probes keyed by name. request_path is only used for Http/Https probes."
  type = map(object({
    protocol            = string
    port                = number
    request_path        = optional(string)
    interval_in_seconds = optional(number, 15)
    number_of_probes    = optional(number, 2)
    probe_threshold     = optional(number, 1)
  }))
  default = {}

  validation {
    condition = alltrue([
      for p in values(var.health_probes) :
      contains(["Tcp", "Http", "Https"], p.protocol)
    ])
    error_message = "Probe protocol must be Tcp, Http or Https."
  }

  validation {
    condition = alltrue([
      for p in values(var.health_probes) :
      (contains(["Http", "Https"], p.protocol) ? p.request_path != null : true)
    ])
    error_message = "Http/Https probes require request_path (e.g. \"/healthz\")."
  }
}

variable "lb_rules" {
  description = <<-EOT
    Load-balancing rules keyed by name. Set enable_ha_ports = true to load-balance
    all ports (frontend/backend ports are then ignored). disable_outbound_snat must
    be true when you provide outbound SNAT via a separate outbound rule or NAT Gateway.
  EOT
  type = map(object({
    frontend_ip_configuration_name = string
    backend_pool_names             = list(string)
    protocol                       = string
    frontend_port                  = optional(number, 0)
    backend_port                   = optional(number, 0)
    probe_name                     = optional(string)
    enable_ha_ports                = optional(bool, false)
    enable_floating_ip             = optional(bool, false)
    enable_tcp_reset               = optional(bool, true)
    disable_outbound_snat          = optional(bool, true)
    idle_timeout_in_minutes        = optional(number, 4)
    load_distribution              = optional(string, "Default")
  }))
  default = {}

  validation {
    condition = alltrue([
      for r in values(var.lb_rules) :
      contains(["Tcp", "Udp", "All"], r.protocol)
    ])
    error_message = "lb_rule protocol must be Tcp, Udp or All (use All only with HA ports)."
  }

  validation {
    condition = alltrue([
      for r in values(var.lb_rules) :
      contains(["Default", "SourceIP", "SourceIPProtocol"], r.load_distribution)
    ])
    error_message = "load_distribution must be Default, SourceIP or SourceIPProtocol."
  }

  validation {
    condition = alltrue([
      for r in values(var.lb_rules) :
      length(r.backend_pool_names) > 0
    ])
    error_message = "Each lb_rule must reference at least one backend pool name."
  }
}

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

outputs.tf

output "id" {
  description = "Resource ID of the Load Balancer."
  value       = azurerm_lb.this.id
}

output "name" {
  description = "Name of the Load Balancer."
  value       = azurerm_lb.this.name
}

output "private_ip_addresses" {
  description = "List of private IP addresses assigned to the LB frontends (empty for purely public LBs)."
  value       = azurerm_lb.this.private_ip_addresses
}

output "frontend_ip_configuration_ids" {
  description = "Map of frontend IP configuration name => id."
  value       = { for f in azurerm_lb.this.frontend_ip_configuration : f.name => f.id }
}

output "backend_pool_ids" {
  description = "Map of backend pool name => id, for NIC/VMSS associations downstream."
  value       = { for k, v in azurerm_lb_backend_address_pool.this : k => v.id }
}

output "probe_ids" {
  description = "Map of health probe name => id."
  value       = { for k, v in azurerm_lb_probe.this : k => v.id }
}

output "lb_rule_ids" {
  description = "Map of load-balancing rule name => id."
  value       = { for k, v in azurerm_lb_rule.this : k => v.id }
}

How to use it

This example provisions an internal Standard Load Balancer for a SQL Server Always On availability group listener. It uses floating IP (Direct Server Return), which the SQL listener requires, plus a dedicated probe port (59999), and then attaches both database NICs to the backend pool via the exported pool id.

resource "azurerm_public_ip" "lb" {
  count               = 0 # internal LB — no public IP needed
  name                = "unused"
  resource_group_name = azurerm_resource_group.db.name
  location            = azurerm_resource_group.db.location
  allocation_method   = "Static"
  sku                 = "Standard"
}

module "load_balancer" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-load-balancer?ref=v1.0.0"

  name                = "lb-sqlag-prod-cin"
  resource_group_name = azurerm_resource_group.db.name
  location            = "centralindia"
  sku                 = "Standard"

  frontend_ip_configurations = [
    {
      name               = "ag-listener"
      subnet_id          = azurerm_subnet.data.id
      private_ip_address = "10.20.4.10"
      zones              = ["1", "2", "3"]
    }
  ]

  backend_pools = {
    "sql-nodes" = {}
  }

  health_probes = {
    "ag-probe" = {
      protocol = "Tcp"
      port     = 59999
    }
  }

  lb_rules = {
    "ag-listener-rule" = {
      frontend_ip_configuration_name = "ag-listener"
      backend_pool_names             = ["sql-nodes"]
      protocol                       = "Tcp"
      frontend_port                  = 1433
      backend_port                   = 1433
      probe_name                     = "ag-probe"
      enable_floating_ip             = true # required for SQL AG listener (DSR)
      disable_outbound_snat          = true
      idle_timeout_in_minutes        = 30
    }
  }

  tags = {
    workload = "sql-alwayson"
    env      = "prod"
  }
}

# Downstream: attach each SQL VM's NIC IP config to the backend pool using an output.
resource "azurerm_network_interface_backend_address_pool_association" "sql" {
  for_each = toset(["sql01", "sql02"])

  network_interface_id    = azurerm_network_interface.sql[each.key].id
  ip_configuration_name   = "internal"
  backend_address_pool_id = module.load_balancer.backend_pool_ids["sql-nodes"]
}

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/load_balancer/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-load-balancer?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
  frontend_ip_configurations = ["...", "..."]
}

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

cd live/prod/load_balancer && 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 Load Balancer (1-80 chars, validated).
resource_group_name string Yes Resource group for the Load Balancer.
location string Yes Azure region (e.g. centralindia).
sku string "Standard" No Basic, Standard, or Gateway. Standard required for zones/HA ports.
sku_tier string "Regional" No Regional or Global (cross-region LB).
frontend_ip_configurations list(object) Yes Frontends; each sets exactly one of public_ip_address_id or subnet_id.
backend_pools map(object) {} No Backend address pools keyed by name; members attached downstream.
health_probes map(object) {} No Probes keyed by name; request_path required for Http/Https.
lb_rules map(object) {} No Load-balancing rules; supports HA ports, floating IP, TCP reset, SNAT control.
tags map(string) {} No Tags applied to the Load Balancer.

Outputs

Name Description
id Resource ID of the Load Balancer.
name Name of the Load Balancer.
private_ip_addresses Private IP addresses on the frontends (empty for public-only LBs).
frontend_ip_configuration_ids Map of frontend name => id.
backend_pool_ids Map of backend pool name => id (for NIC/VMSS associations).
probe_ids Map of health probe name => id.
lb_rule_ids Map of load-balancing rule name => id.

Enterprise scenario

A logistics platform runs its core ERP database on a two-node SQL Server Always On cluster spread across availability zones in Central India. The platform team consumes this module to stand up an internal zone-redundant Standard Load Balancer that fronts the AG listener on 10.20.4.10:1433, with a TCP probe on port 59999 and floating IP enabled so the active replica owns the listener IP. Because the module exposes backend_pool_ids as a map, the same pool is wired into both VM NICs and a future read-scale replica without touching the LB definition, and the listener survives a zone failure with sub-minute failover driven by the health probe.

Best practices

TerraformAzureLoad BalancerModuleIaC
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