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
- You are fronting an HTTP/HTTPS web app or API running on ECS, EC2, EKS, or Lambda and need L7 routing, not just TCP pass-through.
- You want TLS termination at the edge with an ACM certificate and an automatic HTTP-to-HTTPS redirect, instead of managing certs on every instance.
- You need host- or path-based routing (e.g.
api.example.comandapp.example.com, or/v1/*and/v2/*) to different backends behind a single public endpoint. - You require access logs for audit, debugging, or feeding into Athena/CloudWatch, and deletion protection on production load balancers.
- You are standardizing ALBs across many services and want one reviewed, security-hardened module instead of copy-pasted
aws_lbblocks.
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 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/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
- Force TLS at the edge. Keep
enable_http_redirect = trueand a modernssl_policy(the defaultELBSecurityPolicy-TLS13-1-2-2021-06enables TLS 1.3); never expose a plain HTTP listener that forwards traffic. Pair the ALB with AWS WAF on the listener for L7 protection. - Tune health checks to the app, not
/. Point the health check at a dedicated/healthzendpoint, widen the matcher to200-299if needed, and tighteninterval/unhealthy_thresholdso unhealthy targets are pulled fast — the defaults (/,200) silently break apps that redirect or auth-gate the root path. - Keep deletion protection and desync mitigation on in production.
enable_deletion_protection = truestops a strayterraform destroyfrom deleting a live LB, anddrop_invalid_header_fieldsplusdesync_mitigation_mode = "defensive"close off HTTP request-smuggling vectors. - Control cost with the right target type and idle timeout. Use
target_type = "ip"for Fargate to avoid an extra hop, and loweridle_timeoutfor short-lived API traffic so you are not billed LCUs for idle connections; ALB pricing is driven by hours plus LCUs (new connections, active connections, processed bytes, rule evaluations). - Standardize naming and tagging. The
namevalidation enforces the 32-char ALB limit; combine an environment-prefixed name (svc-env) with mandatoryEnvironment/Servicetags so cost allocation and CloudWatch dashboards (keyed onarn_suffix) stay consistent across stacks. - Spread across at least two AZs and keep cross-zone balancing on. Supply subnets in three AZs where possible; the module enables cross-zone load balancing (free on ALB) so traffic is evenly distributed even when target counts differ per zone.