Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 030 / 100

Application Load Balancer with Terraform for production

Route traffic to services.

Expona Admin7 min read

Application Load Balancer with Terraform for production

In production environments, routing traffic efficiently across multiple service instances is non-negotiable. An Application Load Balancer (ALB) sitting in front of your services ensures that requests reach healthy targets, scales gracefully, and terminates SSL/TLS connections at the edge. We'll walk through building a production-grade ALB with Terraform, covering listener configuration, target groups per service, health checks, and HTTP-to-HTTPS redirection.

Why ALB matters in production

Load balancers are often treated as an afterthought, but they're a critical piece of your infrastructure. They distribute incoming traffic across multiple targets, remove unhealthy instances from rotation, and provide a single entry point for your entire application. When you're managing dozens of microservices or scaling an e-commerce platform during peak traffic, a misconfigured or missing load balancer becomes a bottleneck or single point of failure.

Terraform lets us codify this layer, ensuring consistency across environments and making it repeatable. No more clicking through the AWS console or wondering how a load balancer was configured six months ago.

Setting up the listener on 443 with ACM certificate

HTTPS traffic is the only acceptable choice for production. We'll terminate TLS at the ALB using an AWS Certificate Manager (ACM) certificate. First, let's assume you have a certificate already provisioned or validated in ACM (Terraform can create and validate certificates too, but that's a separate concern).

Here's the ALB resource and its HTTPS listener:

hcl
resource "aws_lb" "main" {
  name               = "api-alb-prod"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.public_subnets

  enable_deletion_protection = true

  tags = {
    Name = "api-alb-prod"
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS-1-2-2017-01"
  certificate_arn   = var.acm_certificate_arn

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

resource "aws_security_group" "alb" {
  name   = "alb-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "alb-sg"
  }
}

Key points here: we're using ELBSecurityPolicy-TLS-1-2-2017-01 which enforces TLS 1.2 or higher. For truly sensitive workloads, consider newer policies like ELBSecurityPolicy-TLS-1-2-Ext-2018-06. The enable_deletion_protection prevents accidental destruction in production. The security group allows inbound on both 80 and 443 (we'll redirect 80 to 443 shortly).

Organizing target groups per service

In a microservices architecture, different services need their own target groups. A target group defines where traffic should be routed and how the ALB should health-check those targets. Let's say we have an API service and a worker service:

hcl
resource "aws_lb_target_group" "api" {
  name        = "api-tg-prod"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    enabled             = true
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    path                = "/health"
    matcher             = "200-299"
  }

  deregistration_delay = 30

  tags = {
    Name = "api-tg-prod"
  }
}

resource "aws_lb_target_group" "worker" {
  name        = "worker-tg-prod"
  port        = 3000
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    enabled             = true
    healthy_threshold   = 2
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    path                = "/status"
    matcher             = "200"
  }

  deregistration_delay = 60

  tags = {
    Name = "worker-tg-prod"
  }
}

Each target group specifies the port on which its service listens. The ALB itself listens on 443, but it forwards traffic to port 8080 for the API service. Between the ALB (in a public subnet) and your ECS tasks or EC2 instances (in private subnets), traffic flows over HTTP. The ALB handles the HTTPS termination, so your backend services don't need to manage certificates.

The target_type = "ip" is useful for ECS Fargate and when you're registering tasks by IP. For EC2 instances, use instance.

Implementing health check paths

Health checks are what keep your load balancer from routing traffic to dead instances. Each service should expose a lightweight endpoint that reports its readiness. Notice that the API and worker target groups use different health check paths and matchers. This is intentional:

  • API service: expects /health to return 200-299 status codes, indicating a loose definition of health.
  • Worker service: expects /status to return exactly 200, a stricter check.

You'd configure these endpoints in your application code. A typical health check endpoint looks like this (pseudocode in Node.js):

hcl
app.get("/health", (req, res) => {
  const isHealthy = checkDatabaseConnection() && checkRedisConnection();
  res.status(isHealthy ? 200 : 503).json({ status: "ok" });
});

In Terraform, the health check parameters matter:

  • interval: how often the ALB checks (30 seconds is standard).
  • timeout: how long to wait for a response (5 seconds is reasonable).
  • healthy_threshold: how many consecutive successful checks before marking as healthy (2 is typical).
  • unhealthy_threshold: how many consecutive failures before removing from rotation (3 is common).

With these settings, it takes roughly 60 seconds (30 * 2) to mark a target as healthy after it starts, and 90 seconds (30 * 3) to mark it as unhealthy. Adjust these values based on your deployment lifecycle and acceptable failover time.

Redirecting HTTP to HTTPS

Never let unencrypted HTTP traffic reach your users. Add a listener on port 80 that redirects all traffic to HTTPS:

hcl
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

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

This sends a 301 permanent redirect, telling clients to use HTTPS going forward. Browsers will cache this redirect, reducing the overhead of the redirect loop on subsequent requests.

Routing rules and host-based path-based routing

If you need to route traffic to different target groups based on the hostname or URL path, use listener rules:

hcl
resource "aws_lb_listener_rule" "api_v2" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 10

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.api_v2.arn
  }

  condition {
    path_pattern {
      values = ["/api/v2/*"]
    }
  }
}

resource "aws_lb_listener_rule" "worker_internal" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 20

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.worker.arn
  }

  condition {
    host_header {
      values = ["worker.internal.example.com"]
    }
  }
}

Rules are evaluated in priority order. Lower numbers run first. This allows a single ALB to serve multiple services, which simplifies management and reduces costs.

Common mistakes

  • Forgetting security group rules between ALB and targets: The ALB's security group allows inbound 443, but the target's security group must allow inbound from the ALB on the target port (e.g., 8080). Without this, the ALB can't reach the targets and they'll all fail health checks.
  • Health check endpoints that are too strict: If your health check endpoint depends on external services (database, cache), transient failures cause the target to be removed from rotation unnecessarily. Consider a two-tier health check: a fast readiness check and a slower deep check.
  • Not setting deregistration_delay: This connection draining timeout allows in-flight requests to complete before the target is removed. Too short and you'll drop requests during deployments; too long and your deployments take forever. 30–60 seconds is usually right.
  • Using self-signed certificates in production: Always use ACM or a trusted CA. Self-signed certificates fail in production browsers and break HTTPS verification.
  • Mixing IP and instance target types carelessly: If you're using ECS Fargate or dynamic IP assignment, use ip. For EC2 instances with stable IPs, use instance. Mixing them or choosing the wrong type causes registration failures.

Key takeaways

  • An ALB handles HTTP-to-HTTPS redirection and TLS termination, allowing backend services to remain simple and not manage certificates.
  • Use separate target groups for each service to isolate health checks, routing rules, and scaling policies.
  • Health check paths should be lightweight and reflect actual service readiness, not just whether the process is running.
  • Always set enable_deletion_protection on production ALBs to prevent accidental destruction.
  • Listener rules enable a single ALB to route traffic to multiple services based on path or hostname, reducing infrastructure complexity.
  • Ensure security groups permit bidirectional communication between the ALB and targets.
  • Connection draining (deregistration_delay) prevents request drops during graceful deployments.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles