IaC AWS

Terraform Module: AWS Application Load Balancer — production-grade L7 ingress with listeners, target groups, and access logs

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for the AWS Application Load Balancer (aws_lb): wired-up HTTPS listeners, HTTP-to-HTTPS redirect, target groups with health checks, deletion protection, and S3 access logs. 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 "alb" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-alb?ref=v1.0.0"

  name               = "..."           # Name of the ALB (also the Name tag); 1-32 chars, alphan…
  vpc_id             = "..."           # VPC ID in which the target group is created.
  subnet_ids         = ["...", "..."]  # Subnets to attach the ALB to (at least two, in differen…
  security_group_ids = ["...", "..."]  # Security groups attached to the ALB.
  certificate_arn    = "..."           # ACM certificate ARN for the HTTPS (443) listener.
  target_group       = {}              # Default target group config (name, port, protocol, targ…
}

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

What this module is

The AWS Application Load Balancer (ALB) is a Layer 7 load balancer that terminates HTTP/HTTPS, inspects request attributes (host header, path, query string, HTTP method, source IP), and routes to target groups that can hold EC2 instances, IP addresses, or Lambda functions. Unlike a Network Load Balancer, it understands the request — so it can do host- and path-based routing, redirect HTTP to HTTPS, return fixed responses, terminate TLS with an ACM certificate, and integrate natively with AWS WAF.

The trouble is that a correct ALB is never one resource. You need the aws_lb itself, at least one aws_lb_target_group with a tuned health check, one aws_lb_listener for HTTPS (with the cert and a modern SSL policy), and almost always a second listener on port 80 that redirects to 443. Add deletion protection, drop-invalid-headers for security, and S3 access logging, and a hand-rolled ALB easily runs to 80+ lines that everyone copies and subtly gets wrong (forgetting the redirect, leaving drop_invalid_header_fields off, or pointing the health check at / when the app answers on /healthz).

This module wraps all of that into one var-driven unit. You pass a name, the VPC, subnets, a certificate ARN, and a small description of your target group, and you get back a load balancer with sane, secure defaults and a stable set of outputs (the ARN, DNS name, zone ID, and target group ARN) that the rest of your stack can wire into Route 53, ECS, or autoscaling groups.

When to use it

Reach for a Network Load Balancer module instead if you need raw TCP/UDP, ultra-low latency, static IPs, or millions of flows per second. Use API Gateway if you want managed throttling, API keys, and per-request billing rather than an always-on load balancer.

Module structure

terraform-module-aws-alb/
├── versions.tf      # provider + terraform version pins
├── main.tf          # aws_lb + target group + HTTPS/HTTP listeners
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # arn, dns_name, zone_id, target group arn

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Default tags merged onto every resource this module creates.
  tags = merge(
    var.tags,
    {
      Name      = var.name
      ManagedBy = "terraform"
      Module    = "terraform-module-aws-alb"
    }
  )
}

resource "aws_lb" "this" {
  name               = var.name
  load_balancer_type = "application"
  internal           = var.internal
  subnets            = var.subnet_ids
  security_groups    = var.security_group_ids
  ip_address_type    = var.ip_address_type

  # Production hardening: don't let `terraform destroy` nuke a prod LB,
  # and drop malformed headers so backends never see smuggled requests.
  enable_deletion_protection = var.enable_deletion_protection
  drop_invalid_header_fields = var.drop_invalid_header_fields

  idle_timeout                     = var.idle_timeout
  enable_http2                     = var.enable_http2
  enable_cross_zone_load_balancing = true

  desync_mitigation_mode = var.desync_mitigation_mode

  dynamic "access_logs" {
    for_each = var.access_logs_bucket != null ? [1] : []
    content {
      bucket  = var.access_logs_bucket
      prefix  = var.access_logs_prefix
      enabled = true
    }
  }

  tags = local.tags
}

resource "aws_lb_target_group" "this" {
  name        = var.target_group.name
  vpc_id      = var.vpc_id
  port        = var.target_group.port
  protocol    = var.target_group.protocol
  target_type = var.target_group.target_type

  deregistration_delay = var.target_group.deregistration_delay

  health_check {
    enabled             = true
    path                = var.target_group.health_check.path
    protocol            = var.target_group.health_check.protocol
    matcher             = var.target_group.health_check.matcher
    port                = var.target_group.health_check.port
    interval            = var.target_group.health_check.interval
    timeout             = var.target_group.health_check.timeout
    healthy_threshold   = var.target_group.health_check.healthy_threshold
    unhealthy_threshold = var.target_group.health_check.unhealthy_threshold
  }

  # Sticky sessions are opt-in; default cookie keeps a client on one target.
  dynamic "stickiness" {
    for_each = var.target_group.stickiness_enabled ? [1] : []
    content {
      type            = "lb_cookie"
      cookie_duration = var.target_group.stickiness_duration
      enabled         = true
    }
  }

  tags = local.tags

  lifecycle {
    create_before_destroy = true
  }
}

# HTTPS listener: terminates TLS with the ACM cert, forwards to the target group.
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.this.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = var.ssl_policy
  certificate_arn   = var.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.this.arn
  }

  tags = local.tags
}

# Optional HTTP -> HTTPS redirect on port 80 (301).
resource "aws_lb_listener" "http_redirect" {
  count = var.enable_http_redirect ? 1 : 0

  load_balancer_arn = aws_lb.this.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }

  tags = local.tags
}

variables.tf

variable "name" {
  description = "Name of the Application Load Balancer (also used as the Name tag)."
  type        = string

  validation {
    # ALB names: 1-32 chars, alphanumeric and hyphens, no leading/trailing hyphen.
    condition     = can(regex("^[a-zA-Z0-9]([a-zA-Z0-9-]{0,30}[a-zA-Z0-9])?$", var.name))
    error_message = "name must be 1-32 chars, alphanumeric/hyphen, and not start or end with a hyphen."
  }
}

variable "vpc_id" {
  description = "VPC ID in which the target group is created."
  type        = string
}

variable "subnet_ids" {
  description = "Subnet IDs to attach the ALB to (at least two, in different AZs)."
  type        = list(string)

  validation {
    condition     = length(var.subnet_ids) >= 2
    error_message = "An Application Load Balancer requires at least two subnets in different Availability Zones."
  }
}

variable "security_group_ids" {
  description = "Security group IDs to attach to the ALB."
  type        = list(string)
}

variable "certificate_arn" {
  description = "ARN of the ACM certificate used by the HTTPS (443) listener."
  type        = string

  validation {
    condition     = can(regex("^arn:aws[a-zA-Z-]*:acm:", var.certificate_arn))
    error_message = "certificate_arn must be a valid ACM certificate ARN."
  }
}

variable "internal" {
  description = "Whether the ALB is internal (true) or internet-facing (false)."
  type        = bool
  default     = false
}

variable "ip_address_type" {
  description = "IP address type for the ALB: 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 "ssl_policy" {
  description = "SSL/TLS security policy for the HTTPS listener."
  type        = string
  default     = "ELBSecurityPolicy-TLS13-1-2-2021-06"
}

variable "enable_http_redirect" {
  description = "Create an HTTP (80) listener that 301-redirects to HTTPS (443)."
  type        = bool
  default     = true
}

variable "enable_deletion_protection" {
  description = "Protect the ALB from accidental deletion (recommended in production)."
  type        = bool
  default     = true
}

variable "drop_invalid_header_fields" {
  description = "Drop HTTP headers with invalid fields before forwarding to targets."
  type        = bool
  default     = true
}

variable "enable_http2" {
  description = "Enable HTTP/2 on the ALB."
  type        = bool
  default     = true
}

variable "idle_timeout" {
  description = "Connection idle timeout in seconds (1-4000)."
  type        = number
  default     = 60

  validation {
    condition     = var.idle_timeout >= 1 && var.idle_timeout <= 4000
    error_message = "idle_timeout must be between 1 and 4000 seconds."
  }
}

variable "desync_mitigation_mode" {
  description = "How the ALB handles requests that pose a desync (request smuggling) risk: monitor, defensive, or strictest."
  type        = string
  default     = "defensive"

  validation {
    condition     = contains(["monitor", "defensive", "strictest"], var.desync_mitigation_mode)
    error_message = "desync_mitigation_mode must be one of: monitor, defensive, strictest."
  }
}

variable "access_logs_bucket" {
  description = "S3 bucket name for ALB access logs. Set to null to disable access logging."
  type        = string
  default     = null
}

variable "access_logs_prefix" {
  description = "S3 key prefix for ALB access logs."
  type        = string
  default     = "alb"
}

variable "target_group" {
  description = "Configuration for the default target group fronted by the HTTPS listener."
  type = object({
    name                 = string
    port                 = optional(number, 80)
    protocol             = optional(string, "HTTP")
    target_type          = optional(string, "ip")
    deregistration_delay = optional(number, 30)
    stickiness_enabled   = optional(bool, false)
    stickiness_duration  = optional(number, 86400)
    health_check = optional(object({
      path                = optional(string, "/")
      protocol            = optional(string, "HTTP")
      matcher             = optional(string, "200")
      port                = optional(string, "traffic-port")
      interval            = optional(number, 30)
      timeout             = optional(number, 5)
      healthy_threshold   = optional(number, 3)
      unhealthy_threshold = optional(number, 3)
    }), {})
  })

  validation {
    condition     = contains(["instance", "ip", "lambda", "alb"], var.target_group.target_type)
    error_message = "target_group.target_type must be one of: instance, ip, lambda, alb."
  }

  validation {
    condition     = contains(["HTTP", "HTTPS"], var.target_group.protocol)
    error_message = "target_group.protocol must be HTTP or HTTPS for an Application Load Balancer."
  }
}

variable "tags" {
  description = "Tags applied to all resources created by this module."
  type        = map(string)
  default     = {}
}

outputs.tf

output "arn" {
  description = "ARN of the Application Load Balancer."
  value       = aws_lb.this.arn
}

output "id" {
  description = "ID (ARN) of the Application Load Balancer."
  value       = aws_lb.this.id
}

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

output "arn_suffix" {
  description = "ARN suffix of the ALB, for use in CloudWatch metric dimensions."
  value       = aws_lb.this.arn_suffix
}

output "dns_name" {
  description = "DNS name of the ALB (use as a Route 53 alias target)."
  value       = aws_lb.this.dns_name
}

output "zone_id" {
  description = "Canonical hosted zone ID of the ALB (use in a Route 53 alias record)."
  value       = aws_lb.this.zone_id
}

output "target_group_arn" {
  description = "ARN of the default target group (attach ECS services / ASGs here)."
  value       = aws_lb_target_group.this.arn
}

output "target_group_arn_suffix" {
  description = "ARN suffix of the target group, for CloudWatch metric dimensions."
  value       = aws_lb_target_group.this.arn_suffix
}

output "https_listener_arn" {
  description = "ARN of the HTTPS (443) listener, for attaching extra listener rules."
  value       = aws_lb_listener.https.arn
}

How to use it

module "application_load_balancer" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-alb?ref=v1.0.0"

  name               = "kloudvin-web-prod"
  vpc_id             = module.network.vpc_id
  subnet_ids         = module.network.public_subnet_ids
  security_group_ids = [aws_security_group.alb.id]
  certificate_arn    = module.acm.certificate_arn

  internal             = false
  enable_http_redirect = true
  ssl_policy           = "ELBSecurityPolicy-TLS13-1-2-2021-06"

  access_logs_bucket = module.log_bucket.bucket_id
  access_logs_prefix = "kloudvin-web-prod"

  target_group = {
    name        = "kloudvin-web-prod-tg"
    port        = 8080
    protocol    = "HTTP"
    target_type = "ip" # Fargate tasks register by IP
    health_check = {
      path                = "/healthz"
      matcher             = "200-299"
      interval            = 15
      healthy_threshold   = 2
      unhealthy_threshold = 3
    }
  }

  tags = {
    Environment = "production"
    Service     = "kloudvin-web"
  }
}

# Downstream: point a Route 53 alias at the ALB using its outputs.
resource "aws_route53_record" "web" {
  zone_id = data.aws_route53_zone.primary.zone_id
  name    = "app.kloudvin.com"
  type    = "A"

  alias {
    name                   = module.application_load_balancer.dns_name
    zone_id                = module.application_load_balancer.zone_id
    evaluate_target_health = true
  }
}

# Downstream: attach an ECS service to the module's target group.
resource "aws_ecs_service" "web" {
  name            = "kloudvin-web"
  cluster         = module.ecs.cluster_id
  task_definition = aws_ecs_task_definition.web.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  load_balancer {
    target_group_arn = module.application_load_balancer.target_group_arn
    container_name   = "web"
    container_port   = 8080
  }

  network_configuration {
    subnets         = module.network.private_subnet_ids
    security_groups = [aws_security_group.ecs_tasks.id]
  }
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  name = "..."
  vpc_id = "..."
  subnet_ids = ["...", "..."]
  security_group_ids = ["...", "..."]
  certificate_arn = "..."
  target_group = {}
}

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

cd live/prod/alb && 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 ALB (also the Name tag); 1-32 chars, alphanumeric/hyphen.
vpc_id string yes VPC ID in which the target group is created.
subnet_ids list(string) yes Subnets to attach the ALB to (at least two, in different AZs).
security_group_ids list(string) yes Security groups attached to the ALB.
certificate_arn string yes ACM certificate ARN for the HTTPS (443) listener.
internal bool false no Internal (true) vs internet-facing (false).
ip_address_type string "ipv4" no ipv4 or dualstack.
ssl_policy string "ELBSecurityPolicy-TLS13-1-2-2021-06" no SSL/TLS security policy for the HTTPS listener.
enable_http_redirect bool true no Create a port-80 listener that 301-redirects to HTTPS.
enable_deletion_protection bool true no Protect the ALB from accidental deletion.
drop_invalid_header_fields bool true no Drop invalid HTTP headers before forwarding to targets.
enable_http2 bool true no Enable HTTP/2 on the ALB.
idle_timeout number 60 no Connection idle timeout in seconds (1-4000).
desync_mitigation_mode string "defensive" no Request-smuggling protection: monitor, defensive, or strictest.
access_logs_bucket string null no S3 bucket for access logs; null disables logging.
access_logs_prefix string "alb" no S3 key prefix for access logs.
target_group object({...}) yes Default target group config (name, port, protocol, target_type, health_check, stickiness).
tags map(string) {} no Tags applied to all module resources.

Outputs

Name Description
arn ARN of the Application Load Balancer.
id ID (ARN) of the Application Load Balancer.
name Name of the Application Load Balancer.
arn_suffix ARN suffix of the ALB, for CloudWatch metric dimensions.
dns_name DNS name of the ALB (Route 53 alias target).
zone_id Canonical hosted zone ID of the ALB (for Route 53 alias records).
target_group_arn ARN of the default target group (attach ECS services / ASGs here).
target_group_arn_suffix ARN suffix of the target group, for CloudWatch metric dimensions.
https_listener_arn ARN of the HTTPS (443) listener, for attaching extra listener rules.

Enterprise scenario

A retail platform runs a customer-facing storefront on ECS Fargate across three Availability Zones in ap-south-1. The platform team instantiates this module once per environment (storefront-dev, storefront-stg, storefront-prod) from a single pinned Git ref, so every ALB ships with the same TLS 1.3 policy, HTTP-to-HTTPS redirect, drop_invalid_header_fields, and defensive desync mitigation — security review approves the module once instead of auditing each stack. Production sets enable_deletion_protection = true and ships access logs to a central S3 bucket queried with Athena for latency and 5xx investigations, while the target_group_arn output is consumed directly by the ECS service and the dns_name/zone_id outputs feed a Route 53 alias on shop.example.com.

Best practices

TerraformAWSApplication 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