Guide
Matt S
Matt S
Platform engineer at Fortem··10 min read
ecs-albalb-ecs-fargateecs-target-group-health-check

How Do You Put an ALB in Front of ECS Fargate?

The task is RUNNING, but the target group says unhealthy and the URL 503s. Attaching an Application Load Balancer to a Fargate service has three real traps: the awsvpc "ip target type" rule, a health check that never passes, and — at 10+ services — one ALB per service quietly costing more than the compute. Here's the Terraform-native version, with the numbers to debug the 503 and to decide whether to share one ALB.

TL;DR
  • ·Fargate uses the awsvpc network mode, so the target group MUST use target type "ip", not "instance" — tasks are ENIs, not EC2 instances. This is the #1 first-time error.
  • ·ECS registers and deregisters tasks with the target group automatically through its service-linked role. You attach the target group to the service; you don't script registration.
  • ·An unhealthy target is 90% of the time a security-group gap or a health-check path that doesn't return 200. Fix the SG from the ALB to the task port first.
  • ·One ALB per service is simplest and costs ~$16/month each. At 20 services that's ~$330/month — a shared ALB with host/path routing collapses it to one base charge.
  • ·Tune deregistration delay (300s→5s) and stop timeout (30s→2s), and trap SIGTERM, for fast zero-downtime deploys.
Ready to use — ALB + target group + listener for a Fargate service
hcl
# The load balancer and its security group
resource "aws_lb" "app" {
  name               = "app-alb"
  load_balancer_type = "application"
  subnets            = var.public_subnet_ids
  security_groups    = [aws_security_group.alb.id]
}

resource "aws_security_group" "alb" {
  name   = "app-alb-sg"
  vpc_id = var.vpc_id
  ingress { from_port = 443, to_port = 443, 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"] }
}

# Target group — MUST be target_type = "ip" for Fargate (awsvpc)
resource "aws_lb_target_group" "app" {
  name        = "app-tg"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = "/health"
    matcher             = "200"
    interval            = 15
    healthy_threshold   = 2
    unhealthy_threshold = 3
  }

  # Drain fast for short-lived requests (default is 300s)
  deregistration_delay = 5
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.app.arn
  port              = 443
  protocol          = "HTTPS"
  certificate_arn   = var.acm_certificate_arn
  default_action { type = "forward", target_group_arn = aws_lb_target_group.app.arn }
}

# The task's SG must allow the ALB's SG on the container port
resource "aws_security_group_rule" "alb_to_task" {
  type                     = "ingress"
  from_port                = 8080
  to_port                  = 8080
  protocol                 = "tcp"
  security_group_id        = aws_security_group.task.id
  source_security_group_id = aws_security_group.alb.id
}

# Attach the target group to the service — ECS registers tasks for you
resource "aws_ecs_service" "app" {
  name            = "app"
  cluster         = var.cluster_arn
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 3
  launch_type     = "FARGATE"

  health_check_grace_period_seconds = 60

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 8080
  }

  network_configuration {
    subnets         = var.private_subnet_ids
    security_groups = [aws_security_group.task.id]
  }
}

The two lines that break most first attempts: target_type = "ip" and the alb_to_task security-group rule.

Why Fargate changes how you attach a load balancer

Fargate tasks use the awsvpc network mode, so each task is an elastic network interface with its own IP — not an EC2 instance. That's why the target group must use target type ip.

On EC2-backed ECS, a task maps to a host and a port, so the classic target type of instance works — the load balancer routes to an instance ID and a dynamically-mapped port. Fargate has no host you own. Every task gets its own ENI and private IP, and the load balancer has to route to that IP directly. Register a Fargate service against an instance target group and nothing registers at all — no error you'd expect, just zero targets and a 503.

"For services with tasks using the awsvpc network mode, when you create a target group for your service, you must choose ip as the target type, not instance. This is because tasks that use the awsvpc network mode are associated with an elastic network interface, not an Amazon EC2 instance."

AWS ECS documentation, verified July 2026

One upside of the ip target type: it supports cross-VPC connectivity, where the instance type requires the load balancer and tasks in the same VPC.

The Terraform to wire it up (target type ip)

One ALB, one target group with target_type = ip, one listener, and a security-group rule from the ALB to the task port. ECS registers tasks automatically through its service-linked role.

The Ready-to-use block above is the whole thing. The part people miss isn't the ALB — it's the wiring between it and the task. Two security groups: one on the ALB that lets the public in on 443, and a rule on the task's security group that lets the ALB's security group reach the container port. Reference the ALB's SG as the source, not a CIDR — the task should only accept traffic from the load balancer, never the open internet.

You never write registration logic. Amazon ECS holds a service-linked IAM role whose whole job is to register a task with the target group when it starts and deregister it when it stops. You declare the load_balancer block on the service, and ECS keeps the target group in sync with the running tasks as they cycle through deploys and scaling.

Why your target is unhealthy (the 503 debug)

An unhealthy target is almost always one of three things: the security group blocks the ALB on the task port, the health-check path returns non-200, or the app starts slower than the grace period.

AWS maintains an entire knowledge-center article on this one failure, which tells you how common it is. Work the causes in order — security group first, because it's both the most frequent and the easiest to overlook. If the task's SG doesn't allow the ALB's SG on the container port, the health check can't even reach the app, and the target sits unhealthy from the moment it launches.

Root causeSymptomFix
Security group gapTarget stuck unhealthy from launchAllow the ALB's SG inbound on the container port
Health path returns non-200Target flaps or never passesPoint the check at a path that returns 200 (e.g. /health)
Grace period too shortTask killed and restarted in a loopSet healthCheckGracePeriodSeconds above real startup time
Wrong target typeNo targets register at allUse target_type = "ip" for awsvpc / Fargate
Check port mismatchUnhealthy despite app being upMatch the target-group port to the container port

A recurring "unhealthy" that clears and comes back is different from one that never passes — it's usually load or a slow dependency, and that's where metrics earn their keep. Pairing target health with task-level signals is exactly what monitoring ECS Fargate across a fleet is for: a flapping target with a memory alarm behind it tells you the health check isn't the bug, the app is.

Key insight

A newly-registered target only needs one passing health check to go healthy — the healthy-threshold count only applies when a target recovers from unhealthy back to healthy. So a target that never goes healthy on first launch is almost never a threshold problem; it's a reachability or path problem. Chase the security group and the path, not the thresholds.

ALB health checks vs container health checks

Different systems. The ALB health check decides if a target gets traffic; the container health check decides if ECS restarts the task. Run both — keep the grace period longer than startup.

This is a genuinely confusing overlap — practitioners ask on AWS re:Post whether they should use one, the other, or both. The answer is both, because they answer different questions. The target group's health check governs traffic: fail it and the ALB stops routing to that task. The container health check in the task definition governs lifecycle: fail it and ECS kills and replaces the container. A task can be healthy to ECS but pulled from the ALB, or the reverse.

The one setting that ties them together is health_check_grace_period_seconds on the service. It tells ECS to ignore failing ALB health checks for the first N seconds after a task starts, so a slow-booting app isn't killed before it's ready. Set it above your real cold-start time — a JVM service that takes 45 seconds to warm up needs a grace period well past that, or every deploy turns into a restart loop.

One ALB per service, or a shared ALB?

One ALB per service is simplest but costs about $16/month each. A shared ALB with host- or path-based listener rules serves many services on one base charge — the default past a handful of services.

Every ALB carries an hourly base charge whether it's serving one request or a million. At roughly $16.43/month per load balancer, the per-service pattern looks free at three services and turns into a line item at twenty. A single ALB can front dozens of services through listener rules — route api.example.com to one target group and /admin to another — so you pay one base charge for the whole fleet.

ServicesOne ALB each (base)One shared ALB (base)
5 services$82/mo$16/mo
10 services$164/mo$16/mo
20 services$329/mo$16/mo
Base ALB charge only ($0.0225/hr, us-east-1, verified July 2026). LCU charges ($0.008/LCU-hr) add on top for both and scale with traffic.
Key insight

The shared ALB isn't free savings — it trades dollars for coupling. One listener config now affects every service behind it, and a bad rule change can 503 the whole fleet instead of one app. The rule of thumb: per-service ALBs while you have a handful and want blast-radius isolation; a shared ALB once the base charges outweigh the isolation, with each service still on its own unique target group.

Zero-downtime deploys: deregistration delay and SIGTERM

The default 300-second deregistration delay makes every deploy drain slowly. For sub-second services, drop it to 5s, set ECS_CONTAINER_STOP_TIMEOUT to 2s, and trap SIGTERM to drain in-flight requests.

When ECS replaces a task, the ALB stops sending it new requests and waits out the deregistration delay before cutting existing connections. The default is 300 seconds — five minutes of a draining task lingering per deploy. AWS's own guidance: if your responses finish in under a second, set deregistration_delay.timeout_seconds to 5. Don't do this for long-lived requests like slow uploads or streaming — those need the room.

The other half is graceful shutdown. ECS sends SIGTERM, waits ECS_CONTAINER_STOP_TIMEOUT seconds (default 30), then SIGKILLs. If your app ignores SIGTERM, it eats the full wait on every deploy. Trap SIGTERM to stop accepting new connections and finish in-flight ones, and a task that drains in 500ms exits cleanly instead of waiting out the timeout.

javascript
// Trap SIGTERM so in-flight requests finish before the task exits
process.on("SIGTERM", () => {
  server.close(() => process.exit(0)); // stop new conns, drain, then exit
});

The traps that bite at fleet scale

Load balancer config can't be changed in the console after the service is created — only via CLI or CloudFormation. Use a unique target group per service; sharing one breaks deployments.

Two AWS-documented rules cause most of the fleet-scale pain. First: once a service exists, you cannot change its load-balancer configuration from the console — you have to go through the CLI, CloudFormation, or the SDK, and the change forces a new deployment that re-registers every task. Discover this mid-incident and you'll waste time hunting for a console button that isn't there.

Second: give every service its own target group. Sharing one target group across services "might lead to issues during service deployments," per AWS — the two services fight over the same registration set and deploys go sideways. This is the ingress side of ECS networking; for service-to-service traffic inside the fleet, the internal-ALB and DNS options are a separate decision covered in which ECS service discovery you should use — Cloud Map, Service Connect, or an internal ALB for East-West traffic, versus the public ingress ALB here.

If you read this, you might also want to know

Do I need an NLB instead of an ALB for gRPC or raw TCP?

For HTTP/HTTPS and gRPC, the ALB is right — it speaks layer 7 and supports gRPC target groups. For raw TCP/UDP, non-HTTP protocols, or when you need a static IP and ultra-low latency, use a Network Load Balancer. The Fargate wiring is nearly identical: target_type = ip, and ECS registers tasks the same way.

How do I front multiple Fargate services on one domain?

Use one ALB with path- or host-based listener rules. /api forwards to the api target group, /billing to the billing target group; or api.example.com and app.example.com split by host. Each service keeps its own unique target group — you're only sharing the load balancer and its listeners, not the target group.

Can the ALB and the Fargate tasks live in different VPCs?

Yes, if the target group uses target type ip (which Fargate requires anyway). The ip target type supports cross-VPC connectivity; the instance target type does not. You still need routing and security-group rules that let the ALB reach the task IPs across the VPC boundary.

Frequently asked questions

Every ALB, target group, and service — mapped

At 10+ environments, nobody can say which ALB fronts which service, or which target groups are draining money on dead tasks. Fortem maps the whole ECS fleet — load balancers, target groups, and the services behind them — on one screen. Book a 20-minute call and we'll walk yours.

Book a 20-min call
Worth reading