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
- You run internal or internet-facing TCP/UDP services (SQL Always On listeners, RDP/SSH bastions, custom app ports, DNS) that need L4 distribution and health-based eviction rather than HTTP routing.
- You are fronting a VM Scale Set or a set of IaaS VMs and want a single stable frontend IP plus automatic removal of unhealthy instances.
- You need a zone-redundant entry point with a deterministic, repeatable definition across dev/test/prod.
- You are building HA network virtual appliances (firewalls, proxies) and need HA Ports to forward every port through the pool.
- You want outbound SNAT for a private subnet routed through a known public IP, controlled explicitly per rule.
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 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/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
- Always use the Standard SKU in production. Basic is being retired, has no SLA, is not zone-redundant, and silently caps backend pool size — pin
sku = "Standard"and setzones = ["1","2","3"]on internal frontends for region-wide resilience. - Pair the Load Balancer with an explicit egress path. Standard LB is secure-by-default and provides no automatic outbound connectivity; keep
disable_outbound_snat = trueand route egress through a NAT Gateway or a dedicated outbound rule to avoid SNAT port exhaustion under load. - Match the probe to the real health signal, not just the port. A
Tcpprobe only proves the socket is open; use anHttp/Httpsprobe with arequest_pathlike/healthzfor app pools so a hung-but-listening instance is actually evicted. - Reserve floating IP and HA ports for the cases that need them. Enable
enable_floating_iponly for DSR workloads (SQL AG listeners, NVAs); enableenable_ha_portsonly for firewall/appliance pools — turning them on elsewhere causes confusing port-binding behaviour. - Keep TCP reset on for faster failure detection.
enable_tcp_reset = truesends RST on idle timeout so clients fail fast and reconnect instead of hanging until their own timeout fires. - Name and tag for blast-radius clarity. Use a deterministic convention such as
lb-<workload>-<env>-<region>and tagenv/workloadso cost and incident tooling can attribute the LB and its public IP unambiguously.