Quick take — A reusable Terraform module for the AWS Network Load Balancer (aws_lb network type): static EIPs per subnet, TCP/TLS/UDP listeners, target groups with health checks, and cross-zone load balancing wired for production. 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 "aws" {
region = "us-east-1"
}
module "nlb" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-nlb?ref=v1.0.0"
name = "..." # NLB name (1–32 chars, alphanumeric + hyphens); also pre…
vpc_id = "..." # VPC ID in which target groups are created.
subnet_ids = ["...", "..."] # Subnets (one per AZ) for the NLB; one EIP per subnet wh…
target_groups = {} # Target groups keyed by logical name (port, protocol, ta…
listeners = {} # Listeners keyed by logical name, each forwarding to a `…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
The AWS Network Load Balancer (NLB) is a Layer-4 (TCP/UDP/TLS) load balancer that operates at the connection level rather than inspecting HTTP. Unlike the Application Load Balancer, it forwards packets without terminating the application protocol, gives you ultra-low latency, scales to millions of flows per second, and — critically — exposes a stable set of IP addresses. You can attach one Elastic IP per Availability Zone, which is what makes the NLB the right tool whenever a downstream consumer needs to allow-list a fixed IP, when you front a PrivateLink VPC endpoint service, or when you terminate raw TCP/TLS for protocols an ALB cannot speak (databases, MQTT, custom binary protocols, gRPC over raw TCP).
In raw Terraform, a production NLB is rarely a single aws_lb block. You almost always also need aws_lb_target_group (with a health check tuned for TCP), one or more aws_lb_listener blocks (often a TLS listener referencing an ACM certificate), and per-AZ subnet/EIP mappings. Wiring those four resources together correctly — and repeating it identically for every team and every environment — is exactly the kind of boilerplate that drifts. This module wraps aws_lb (type network) plus its target groups and listeners behind a small, validated variable surface so every NLB in the estate is provisioned the same way: deletion protection on in prod, cross-zone behaviour explicit, access logs shippable to S3, and a clean set of outputs (ARN, DNS name, zone ID, hosted zone) that downstream Route 53 and PrivateLink stacks can consume.
When to use it
- You need a static, allow-listable IP address for ingress (one EIP per AZ) — a partner firewall or an on-prem ACL only accepts fixed IPs and cannot follow a DNS-only endpoint.
- You are exposing a service over PrivateLink —
aws_vpc_endpoint_servicerequires a Network Load Balancer (or GWLB) as its backend. - You terminate raw TCP, UDP, or TLS rather than HTTP: PostgreSQL/MySQL proxies, Redis, Kafka brokers, MQTT/IoT, SMTP, game servers, or gRPC where you want TLS passthrough/termination without HTTP semantics.
- You need extreme throughput and low, consistent latency with source-IP preservation, and the connection-oriented behaviour of Layer 4 rather than the request routing of Layer 7.
- Reach for an Application Load Balancer instead when you need host/path routing, HTTP header manipulation, WAF integration, or OIDC authentication. Use this NLB module when the workload is genuinely Layer 4.
Module structure
terraform-module-aws-nlb/
├── versions.tf # provider + Terraform version pins
├── main.tf # aws_lb (network) + target groups + listeners + EIPs
├── variables.tf # validated input surface
└── outputs.tf # ARN, DNS name, zone_id, target group / listener maps
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# When internet-facing and the caller asked for static IPs, allocate one EIP
# per provided subnet and use subnet_mapping instead of a plain subnets list.
use_eips = var.internal == false && var.enable_static_ips
eip_subnets = local.use_eips ? var.subnet_ids : []
}
# One Elastic IP per AZ — these are the stable, allow-listable addresses.
resource "aws_eip" "this" {
for_each = toset(local.eip_subnets)
domain = "vpc"
tags = merge(
var.tags,
{ Name = "${var.name}-eip-${each.key}" }
)
}
resource "aws_lb" "this" {
name = var.name
load_balancer_type = "network"
internal = var.internal
enable_cross_zone_load_balancing = var.enable_cross_zone_load_balancing
enable_deletion_protection = var.enable_deletion_protection
ip_address_type = var.ip_address_type
# Preserve the original client source IP through the NLB to the targets.
enable_zonal_shift = var.enable_zonal_shift
security_groups = length(var.security_group_ids) > 0 ? var.security_group_ids : null
# Static-IP path: explicit subnet -> EIP mapping. Otherwise a plain subnet list.
dynamic "subnet_mapping" {
for_each = local.use_eips ? var.subnet_ids : []
content {
subnet_id = subnet_mapping.value
allocation_id = aws_eip.this[subnet_mapping.value].id
}
}
subnets = local.use_eips ? null : var.subnet_ids
dynamic "access_logs" {
for_each = var.access_logs_bucket == null ? [] : [1]
content {
enabled = true
bucket = var.access_logs_bucket
prefix = var.access_logs_prefix
}
}
tags = merge(
var.tags,
{ Name = var.name }
)
}
# Target groups — one per entry in var.target_groups.
resource "aws_lb_target_group" "this" {
for_each = var.target_groups
name = "${var.name}-${each.key}"
vpc_id = var.vpc_id
port = each.value.port
protocol = each.value.protocol
target_type = each.value.target_type
preserve_client_ip = each.value.preserve_client_ip
proxy_protocol_v2 = each.value.proxy_protocol_v2
deregistration_delay = each.value.deregistration_delay
connection_termination = each.value.connection_termination
health_check {
enabled = true
protocol = each.value.health_check.protocol
port = each.value.health_check.port
path = each.value.health_check.protocol == "HTTP" || each.value.health_check.protocol == "HTTPS" ? each.value.health_check.path : null
interval = each.value.health_check.interval
healthy_threshold = each.value.health_check.healthy_threshold
unhealthy_threshold = each.value.health_check.unhealthy_threshold
}
tags = merge(
var.tags,
{ Name = "${var.name}-${each.key}" }
)
lifecycle {
create_before_destroy = true
}
}
# Listeners — one per entry in var.listeners, forwarding to a named target group.
resource "aws_lb_listener" "this" {
for_each = var.listeners
load_balancer_arn = aws_lb.this.arn
port = each.value.port
protocol = each.value.protocol
# TLS-only attributes; null/ignored for TCP/UDP/TCP_UDP listeners.
certificate_arn = each.value.protocol == "TLS" ? each.value.certificate_arn : null
ssl_policy = each.value.protocol == "TLS" ? each.value.ssl_policy : null
alpn_policy = each.value.protocol == "TLS" ? each.value.alpn_policy : null
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.this[each.value.target_group_key].arn
}
tags = var.tags
}
# variables.tf
variable "name" {
description = "Name of the Network Load Balancer (also used as a prefix for EIPs and target groups)."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9-]{1,32}$", var.name))
error_message = "name must be 1-32 characters, alphanumeric and hyphens only (NLB name constraint)."
}
}
variable "vpc_id" {
description = "VPC ID in which the target groups are created."
type = string
}
variable "subnet_ids" {
description = "Subnet IDs (one per AZ) the NLB is attached to. With static IPs enabled, one EIP is allocated per subnet."
type = list(string)
validation {
condition = length(var.subnet_ids) >= 1
error_message = "Provide at least one subnet ID; production deployments should span at least two AZs."
}
}
variable "internal" {
description = "If true, the NLB is internal (private). If false, it is internet-facing."
type = bool
default = true
}
variable "enable_static_ips" {
description = "When internet-facing, allocate one Elastic IP per subnet for stable, allow-listable addresses. Ignored for internal NLBs."
type = bool
default = false
}
variable "ip_address_type" {
description = "IP address type for the NLB: ipv4 or dualstack."
type = string
default = "ipv4"
validation {
condition = contains(["ipv4", "dualstack"], var.ip_address_type)
error_message = "ip_address_type must be either 'ipv4' or 'dualstack'."
}
}
variable "enable_cross_zone_load_balancing" {
description = "Distribute traffic evenly across all AZs. Note: cross-zone traffic on an NLB incurs inter-AZ data charges."
type = bool
default = true
}
variable "enable_deletion_protection" {
description = "Prevent the NLB from being deleted via the API/Terraform. Recommended true in production."
type = bool
default = false
}
variable "enable_zonal_shift" {
description = "Enable ARC zonal shift so traffic can be drained from an impaired AZ."
type = bool
default = false
}
variable "security_group_ids" {
description = "Optional security groups for the NLB. NLB security groups are immutable after creation; leave empty for the legacy no-SG behaviour."
type = list(string)
default = []
}
variable "access_logs_bucket" {
description = "S3 bucket name for NLB access logs (TLS listeners only). Null disables access logging."
type = string
default = null
}
variable "access_logs_prefix" {
description = "S3 key prefix for access logs."
type = string
default = null
}
variable "target_groups" {
description = "Map of target groups keyed by a short logical name."
type = map(object({
port = number
protocol = string # TCP | UDP | TCP_UDP | TLS
target_type = optional(string, "instance")
preserve_client_ip = optional(bool, null)
proxy_protocol_v2 = optional(bool, false)
deregistration_delay = optional(number, 300)
connection_termination = optional(bool, false)
health_check = object({
protocol = optional(string, "TCP") # TCP | HTTP | HTTPS
port = optional(string, "traffic-port")
path = optional(string, "/")
interval = optional(number, 30)
healthy_threshold = optional(number, 3)
unhealthy_threshold = optional(number, 3)
})
}))
validation {
condition = alltrue([
for tg in values(var.target_groups) :
contains(["TCP", "UDP", "TCP_UDP", "TLS"], tg.protocol)
])
error_message = "Each target group protocol must be one of TCP, UDP, TCP_UDP, or TLS."
}
validation {
condition = alltrue([
for tg in values(var.target_groups) :
contains(["instance", "ip", "alb"], tg.target_type)
])
error_message = "target_type must be one of instance, ip, or alb."
}
}
variable "listeners" {
description = "Map of listeners keyed by a short logical name, each forwarding to a target_group_key."
type = map(object({
port = number
protocol = string # TCP | UDP | TCP_UDP | TLS
target_group_key = string
certificate_arn = optional(string, null)
ssl_policy = optional(string, "ELBSecurityPolicy-TLS13-1-2-2021-06")
alpn_policy = optional(string, null)
}))
validation {
condition = alltrue([
for l in values(var.listeners) :
contains(["TCP", "UDP", "TCP_UDP", "TLS"], l.protocol)
])
error_message = "Each listener protocol must be one of TCP, UDP, TCP_UDP, or TLS."
}
validation {
condition = alltrue([
for l in values(var.listeners) :
l.protocol != "TLS" || l.certificate_arn != null
])
error_message = "A TLS listener requires certificate_arn to be set."
}
}
variable "tags" {
description = "Tags applied to all resources created by the module."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "The ARN of the Network Load Balancer (id and arn are identical for aws_lb)."
value = aws_lb.this.id
}
output "arn" {
description = "The ARN of the Network Load Balancer."
value = aws_lb.this.arn
}
output "name" {
description = "The name of the Network Load Balancer."
value = aws_lb.this.name
}
output "dns_name" {
description = "The DNS name of the NLB — use as a Route 53 alias target."
value = aws_lb.this.dns_name
}
output "zone_id" {
description = "The canonical hosted zone ID of the NLB, required for Route 53 alias records."
value = aws_lb.this.zone_id
}
output "arn_suffix" {
description = "The ARN suffix of the NLB, for use in CloudWatch metric dimensions."
value = aws_lb.this.arn_suffix
}
output "static_ip_allocation_ids" {
description = "Map of subnet ID to Elastic IP allocation ID (empty unless static IPs are enabled)."
value = { for k, eip in aws_eip.this : k => eip.id }
}
output "static_public_ips" {
description = "Map of subnet ID to the static public IP address (the allow-listable IPs)."
value = { for k, eip in aws_eip.this : k => eip.public_ip }
}
output "target_group_arns" {
description = "Map of target group logical name to its ARN — register targets against these."
value = { for k, tg in aws_lb_target_group.this : k => tg.arn }
}
output "listener_arns" {
description = "Map of listener logical name to its ARN."
value = { for k, l in aws_lb_listener.this : k => l.arn }
}
How to use it
module "network_load_balancer" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-nlb?ref=v1.0.0"
name = "payments-tls-prod"
vpc_id = data.aws_vpc.prod.id
subnet_ids = data.aws_subnets.public.ids # one public subnet per AZ
internal = false
enable_static_ips = true # partner firewalls allow-list these EIPs
enable_cross_zone_load_balancing = true
enable_deletion_protection = true
enable_zonal_shift = true
access_logs_bucket = aws_s3_bucket.lb_logs.id
access_logs_prefix = "nlb/payments"
target_groups = {
app = {
port = 8443
protocol = "TCP"
target_type = "ip"
preserve_client_ip = true
health_check = {
protocol = "HTTPS"
port = "8443"
path = "/healthz"
}
}
}
listeners = {
tls = {
port = 443
protocol = "TLS"
target_group_key = "app"
certificate_arn = aws_acm_certificate.payments.arn
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
}
}
tags = {
Environment = "prod"
Team = "payments-platform"
CostCenter = "CC-4471"
}
}
# Downstream: publish the NLB behind a friendly DNS name using its outputs.
resource "aws_route53_record" "payments" {
zone_id = data.aws_route53_zone.corp.zone_id
name = "payments.kloudvin.com"
type = "A"
alias {
name = module.network_load_balancer.dns_name
zone_id = module.network_load_balancer.zone_id
evaluate_target_health = true
}
}
# Downstream: expose the same NLB as a PrivateLink endpoint service for partner VPCs.
resource "aws_vpc_endpoint_service" "payments" {
acceptance_required = true
network_load_balancer_arns = [module.network_load_balancer.arn]
tags = {
Name = "payments-tls-prod-pls"
}
}
# Register application instances/IPs against the module's target group.
resource "aws_lb_target_group_attachment" "app" {
for_each = toset(var.app_target_ips)
target_group_arn = module.network_load_balancer.target_group_arns["app"]
target_id = each.value
port = 8443
}
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 = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/nlb/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-nlb?ref=v1.0.0"
}
inputs = {
name = "..."
vpc_id = "..."
subnet_ids = ["...", "..."]
target_groups = {}
listeners = {}
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/nlb && 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 | NLB name (1–32 chars, alphanumeric + hyphens); also prefixes EIPs and target groups. |
vpc_id |
string |
— | Yes | VPC ID in which target groups are created. |
subnet_ids |
list(string) |
— | Yes | Subnets (one per AZ) for the NLB; one EIP per subnet when static IPs are enabled. |
internal |
bool |
true |
No | true = internal/private NLB; false = internet-facing. |
enable_static_ips |
bool |
false |
No | Allocate one EIP per subnet for stable allow-listable IPs (internet-facing only). |
ip_address_type |
string |
"ipv4" |
No | ipv4 or dualstack. |
enable_cross_zone_load_balancing |
bool |
true |
No | Even distribution across AZs (incurs inter-AZ data charges on NLB). |
enable_deletion_protection |
bool |
false |
No | Block deletion via API/Terraform; set true in prod. |
enable_zonal_shift |
bool |
false |
No | Allow ARC zonal shift to drain an impaired AZ. |
security_group_ids |
list(string) |
[] |
No | Optional NLB security groups (immutable after create). |
access_logs_bucket |
string |
null |
No | S3 bucket for access logs (TLS listeners); null disables logging. |
access_logs_prefix |
string |
null |
No | S3 key prefix for access logs. |
target_groups |
map(object) |
— | Yes | Target groups keyed by logical name (port, protocol, target_type, health check, etc.). |
listeners |
map(object) |
— | Yes | Listeners keyed by logical name, each forwarding to a target_group_key. |
tags |
map(string) |
{} |
No | Tags applied to all created resources. |
Outputs
| Name | Description |
|---|---|
id |
ARN of the NLB (identical to arn for aws_lb). |
arn |
ARN of the Network Load Balancer. |
name |
Name of the Network Load Balancer. |
dns_name |
DNS name of the NLB — use as a Route 53 alias target. |
zone_id |
Canonical hosted zone ID, required for Route 53 alias records. |
arn_suffix |
ARN suffix for CloudWatch metric dimensions. |
static_ip_allocation_ids |
Map of subnet ID → EIP allocation ID (empty unless static IPs enabled). |
static_public_ips |
Map of subnet ID → static public IP (the allow-listable addresses). |
target_group_arns |
Map of target group logical name → ARN, for registering targets. |
listener_arns |
Map of listener logical name → ARN. |
Enterprise scenario
A payments platform must expose a mutual-TLS API to three banking partners whose firewalls only permit egress to a fixed set of destination IPs. The team deploys this module as an internet-facing NLB with enable_static_ips = true across three AZs, publishing the resulting Elastic IPs to each partner for their allow-lists, while a TLS listener on 443 terminates against an ACM certificate and forwards to IP targets running the payment proxy with preserve_client_ip = true. The same NLB ARN is simultaneously fed into an aws_vpc_endpoint_service, so internal consumer VPCs reach the service privately over PrivateLink without traversing the internet — one module instance serving both the public allow-listed path and the private PrivateLink path.
Best practices
- Pin static IPs only when you need them. Each Elastic IP is a fixed dependency partners come to rely on — once an allow-list is built around an EIP, treat that allocation as immutable and never let Terraform recreate it. For internal NLBs, skip EIPs entirely and consume the
dns_name/zone_idoutputs via Route 53 alias. - Mind cross-zone data charges. Unlike the ALB (where cross-zone is free), NLB cross-zone load balancing bills inter-AZ data transfer. Enable it for even target utilisation, but for chatty internal traffic consider leaving it off and ensuring every AZ has healthy targets.
- Always enable deletion protection and access logs in production. Set
enable_deletion_protection = trueso a strayterraform destroycannot drop a partner-facing endpoint, and ship TLS access logs to a dedicated, encrypted S3 bucket for audit and incident forensics. - Tune TCP health checks deliberately. Default TCP health checks only verify the port is open. Where the target speaks HTTP, point the health check at an
HTTPS/HTTP/healthzpath so an unhealthy-but-listening process is actually drained; keepunhealthy_thresholdlow for fast failover. - Use the strongest TLS policy and a single source of certs. Default
ssl_policytoELBSecurityPolicy-TLS13-1-2-2021-06for TLS 1.3, source certificates from ACM (auto-renewing), and prefertarget_type = "ip"withpreserve_client_ip = truewhen downstream services need the real client address. - Name and tag for the estate. Encode environment and team in
name(e.g.payments-tls-prod) so the NLB, its EIPs, and target groups are self-describing in the console, and propagateCostCenter/Environmenttags throughvar.tagsfor showback and policy enforcement.