DEV Community

Cover image for Making My ECS App Public with an Application Load Balancer
Lalit Bagga
Lalit Bagga

Posted on • Originally published at blog.lalitbagga.com

Making My ECS App Public with an Application Load Balancer

My Node.js app was running on ECS Fargate and the CloudWatch logs confirmed it was healthy. But there was no way to actually reach it. No public URL. No way to test it from a browser. The container was alive but invisible.

The fix is an Application Load Balancer. Here is how I wired it up.


Why the App Was Unreachable

ECS Fargate tasks run in private subnets by design. No public IP, no direct internet access. That is the correct security posture for an application server. You never want your app directly exposed to the internet.

The ALB sits in the public subnet and acts as the entry point. Traffic flows like this:

Internet
        ↓
ALB (public subnet, internet facing)
        ↓
ECS Fargate task (private subnet, no public IP)
Enter fullscreen mode Exit fullscreen mode

The app never needs a public IP. Only the ALB does.


What You Need to Wire Together

An ALB setup has three moving parts that work together:

Security Group controls who can connect to the ALB. Port 80 open to the world.

Target Group is where the ALB sends traffic after it is allowed in. It holds a list of healthy ECS tasks and runs health checks against them. If a task fails health checks it gets removed from rotation.

Listener watches a specific port on the ALB and decides what to do with incoming traffic. In this case listen on port 80 and forward to the target group.

A common mistake is thinking the security group is enough. It is not. The security group decides whether traffic is allowed. The listener decides what to do with it. The target group decides where it goes. All three are required.


One Thing ALB Requires

An ALB must span at least two subnets in different Availability Zones. This is an AWS requirement, not optional.

The reason is high availability. If one AZ goes down the ALB continues serving traffic through the other. You get one ALB, one DNS name, one entry point. AWS handles the redundancy behind the scenes.

This means your VPC needs public subnets in at least two different AZs. If you are defining subnets in Terraform always specify the availability zone explicitly:

resource "aws_subnet" "main_subnet_public_1" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "us-east-2a"
}

resource "aws_subnet" "main_subnet_public_2" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "us-east-2b"
}
Enter fullscreen mode Exit fullscreen mode

If you leave out availability_zone AWS picks one automatically. You may end up with both subnets in the same AZ and the ALB will refuse to deploy.


Terraform for the ALB

I added an ALB module to my existing three tier Terraform project.

Security group for the ALB:

resource "aws_security_group" "alb_sg" {
  name   = "alb_sg"
  vpc_id = var.vpc_id

  tags = {
    Name = "alb_sg"
  }
}

resource "aws_vpc_security_group_ingress_rule" "alb_sg_ingress" {
  security_group_id = aws_security_group.alb_sg.id
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  cidr_ipv4         = "0.0.0.0/0"
}

resource "aws_vpc_security_group_egress_rule" "alb_sg_egress" {
  security_group_id = aws_security_group.alb_sg.id
  ip_protocol       = "-1"
  cidr_ipv4         = "0.0.0.0/0"
}
Enter fullscreen mode Exit fullscreen mode

The ALB, target group, and listener:

resource "aws_lb" "main" {
  name               = "three-tier-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [var.alb_sg_id]
  subnets            = [var.public_subnet_1_id, var.public_subnet_2_id]

  tags = {
    Name = "three-tier-alb"
  }
}

resource "aws_lb_target_group" "main" {
  name        = "three-tier-tg"
  port        = 3000
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
  }
}

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

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.main.arn
  }
}
Enter fullscreen mode Exit fullscreen mode

One thing worth noting on the target group: target_type = "ip" is required for Fargate. Fargate tasks do not have EC2 instance IDs. They have IP addresses. If you use instance as the target type the target group will never register any healthy targets and you will get 503s.


Locking Down the ECS Security Group

Before adding the ALB my ECS security group allowed inbound traffic from anywhere on port 3000. Once the ALB is in place that should change. Only the ALB should be able to talk to the ECS tasks. Not the public internet.

resource "aws_vpc_security_group_ingress_rule" "ecs_sg_ingress" {
  security_group_id            = aws_security_group.ecs_sg.id
  from_port                    = 3000
  to_port                      = 3000
  ip_protocol                  = "tcp"
  referenced_security_group_id = aws_security_group.alb_sg.id
}
Enter fullscreen mode Exit fullscreen mode

Instead of cidr_ipv4 = "0.0.0.0/0" the ingress rule now references the ALB security group directly. Only traffic coming from the ALB is allowed to reach the ECS task on port 3000.


Wiring ECS to the Target Group

The ECS service needs to know about the target group so it can register tasks automatically. When a new task starts ECS registers its IP with the target group. When a task stops it deregisters.

resource "aws_ecs_service" "app" {
  name            = "three-tier-app-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = [var.private_subnet_id]
    security_groups = [var.ecs_sg_id]
  }

  load_balancer {
    target_group_arn = var.target_group_arn
    container_name   = "app-container"
    container_port   = 3000
  }

  depends_on = [var.alb_listener_arn]
}
Enter fullscreen mode Exit fullscreen mode

The depends_on on the listener matters. ECS should not start registering tasks with the target group until the listener exists. Without this Terraform might try to create the ECS service before the listener is ready and health checks will fail in a loop.


The Result

After terraform apply the output shows the ALB DNS name:

alb_dns_name = "three-tier-alb-119067097.us-east-2.elb.amazonaws.com"
Enter fullscreen mode Exit fullscreen mode

Hitting that URL in the browser returns the app response. Hitting /health returns a healthy status. The app is now publicly accessible through the load balancer with the ECS task completely hidden in the private subnet.


What Is Next

The app is live but there is no visibility into what it is actually doing. The next step is adding Prometheus and Grafana to collect real metrics and build dashboards, along with load testing and crash recovery to prove the infrastructure holds up under pressure.

aws#ecs#terraform#devops#loadbalancer

Top comments (0)