IaC AWS

Terraform Module: AWS Network Load Balancer — Layer-4 ingress with static IPs, TLS termination, and cross-zone control

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

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

TerraformAWSNetwork Load 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