LAB28_AWS_Production ECS Fargate Service: ALB, Auto Scaling, and Secrets Manager

Production ECS Fargate Service: ALB, Auto Scaling, and Secrets Manager

☁ AWS 🔥 Advanced ECS Fargate ALB Application Auto Scaling Secrets Manager Target Tracking

Scenario

Your containerised web API needs to run in a production-grade configuration: traffic must enter through an Application Load Balancer, the ECS service must scale automatically based on CPU load, and the container must retrieve its database password from Secrets Manager at runtime — never from an environment variable hardcoded at deploy time.

Build the full stack: VPC networking, ALB, ECS Fargate service attached to the ALB target group, Application Auto Scaling with a CPU target tracking policy, and Secrets Manager integration for the task role.

Your Objectives
  • Create an internet-facing ALB in public subnets with an HTTP listener forwarding to an ECS target group (type ip for Fargate).
  • Create an ECS Fargate cluster and a task definition that pulls the database secret ARN via an secrets block (injecting it as an environment variable at task launch time).
  • Assign the task role secretsmanager:GetSecretValue and kms:Decrypt on the specific secret only.
  • Create an ECS service with load_balancer configuration pointing to the target group.
  • Register the ECS service as a scalable target (aws_appautoscaling_target) and attach a CPU target tracking policy targeting 70% average CPU.
  • Output the ALB DNS name and the service ARN.

Additional Context

Fargate target groups must use target_type = "ip", not instance. Fargate tasks get their own ENI (elastic network interface) with a private IP — the ALB registers the task's private IP directly, not the host EC2 instance (there is no host EC2 with Fargate). This is different from EC2 launch type where the instance ID is registered.

The ECS task definition secrets block (inside container definitions) tells Fargate to retrieve the value from Secrets Manager or SSM Parameter Store at task launch time and inject it as an environment variable. The value never touches Terraform state — it goes directly from Secrets Manager to the container environment.

variables.tf

variable "aws_region"       { type = string; default = "us-east-1" }
variable "cluster_name"     { type = string; default = "web-api-cluster" }
variable "container_image"  { type = string; description = "Full container image URI (e.g., ECR URI:tag)." }
variable "secret_arn"       { type = string; description = "Secrets Manager ARN containing DB credentials." }
variable "vpc_id"           { type = string }
variable "public_subnets"   { type = list(string); description = "Public subnets for ALB." }
variable "private_subnets"  { type = list(string); description = "Private subnets for Fargate tasks." }
variable "environment"      { type = string; default = "prod" }

main.tf

# main.tf

provider "aws" { region = var.aws_region }

locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }

# ALB security group — allow inbound HTTP.
resource "aws_security_group" "alb" {
  name   = "alb-sg-${var.environment}"
  vpc_id = var.vpc_id
  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   = local.tags
}

# ECS tasks security group — allow inbound only from ALB.
resource "aws_security_group" "tasks" {
  name   = "ecs-tasks-sg-${var.environment}"
  vpc_id = var.vpc_id
  ingress { from_port = 8080; to_port = 8080; protocol = "tcp"; security_groups = [aws_security_group.alb.id] }
  egress  { from_port = 0;    to_port = 0;    protocol = "-1";  cidr_blocks = ["0.0.0.0/0"] }
  tags   = local.tags
}

# Application Load Balancer.
resource "aws_lb" "main" {
  name               = "${var.cluster_name}-alb"
  load_balancer_type = "application"
  internal           = false
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.public_subnets
  tags               = local.tags
}

# Target group — MUST use target_type = "ip" for Fargate.
resource "aws_lb_target_group" "web" {
  name        = "${var.cluster_name}-tg"
  port        = 8080
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"  # <-- critical for Fargate

  health_check {
    path                = "/health"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 15
  }
  tags = local.tags
}

# HTTP Listener — forwards to ECS target group.
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn
  port              = 80
  protocol          = "HTTP"
  default_action { type = "forward"; target_group_arn = aws_lb_target_group.web.arn }
}

# ECS Cluster.
resource "aws_ecs_cluster" "main" {
  name = var.cluster_name
  setting { name = "containerInsights"; value = "enabled" }
  tags = local.tags
}

# Common trust policy for Fargate tasks.
data "aws_iam_policy_document" "ecs_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals { type = "Service"; identifiers = ["ecs-tasks.amazonaws.com"] }
  }
}

# Execution role — allows Fargate to pull ECR images and retrieve Secrets Manager secrets.
resource "aws_iam_role" "execution" {
  name               = "${var.cluster_name}-exec-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

resource "aws_iam_role_policy_attachment" "exec_policy" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# Extra policy so execution role can read the specific secret during task launch.
resource "aws_iam_role_policy" "exec_secrets" {
  name   = "allow-secret-read"
  role   = aws_iam_role.execution.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = [var.secret_arn]
    }]
  })
}

# Task role — assumed by the running container to call AWS APIs.
resource "aws_iam_role" "task" {
  name               = "${var.cluster_name}-task-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}

# CloudWatch Log Group.
resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${var.cluster_name}"
  retention_in_days = 30
}

# Task Definition — uses secrets block to inject DB credentials at launch time.
resource "aws_ecs_task_definition" "web" {
  family                   = "${var.cluster_name}-web"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "512"
  memory                   = "1024"
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name      = "web"
    image     = var.container_image
    essential = true
    portMappings = [{ containerPort = 8080; protocol = "tcp" }]

    # secrets block: Fargate reads from Secrets Manager at task start and
    # injects the JSON value as an environment variable. Never touches Terraform state.
    secrets = [
      { name = "DB_CREDENTIALS"; valueFrom = var.secret_arn }
    ]

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.ecs.name
        "awslogs-region"        = var.aws_region
        "awslogs-stream-prefix" = "web"
      }
    }
  }])
}

# ECS Service — attaches to the ALB target group.
resource "aws_ecs_service" "web" {
  name            = "${var.cluster_name}-web-svc"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.web.arn
  desired_count   = 2
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.private_subnets
    security_groups  = [aws_security_group.tasks.id]
    assign_public_ip = false
  }

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

  deployment_minimum_healthy_percent = 50
  deployment_maximum_percent         = 200
  depends_on = [aws_lb_listener.http]
}

# Application Auto Scaling — register the ECS service as a scalable target.
resource "aws_appautoscaling_target" "ecs" {
  max_capacity       = 10
  min_capacity       = 2
  resource_id        = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.web.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

# Target tracking policy — scale to keep average CPU at 70%.
resource "aws_appautoscaling_policy" "cpu" {
  name               = "${var.cluster_name}-cpu-tracking"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.ecs.resource_id
  scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
  service_namespace  = aws_appautoscaling_target.ecs.service_namespace

  target_tracking_scaling_policy_configuration {
    target_value       = 70   # scale out when average CPU > 70%
    scale_in_cooldown  = 300
    scale_out_cooldown = 60

    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
  }
}

outputs.tf

output "alb_dns_name"   { value = aws_lb.main.dns_name }
output "service_arn"    { value = aws_ecs_service.web.id }
output "cluster_name"   { value = aws_ecs_cluster.main.name }

Workflow Commands

terraform init
terraform apply \
  -var="container_image=123456789012.dkr.ecr.us-east-1.amazonaws.com/web-api:v1.0.0" \
  -var="secret_arn=arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/credentials-xxxxxx" \
  -var="vpc_id=vpc-xxxx" \
  -var='public_subnets=["subnet-pub-a","subnet-pub-b"]' \
  -var='private_subnets=["subnet-priv-a","subnet-priv-b"]'

# Hit the endpoint
curl http://$(terraform output -raw alb_dns_name)/health

# Check service scaling activity
aws application-autoscaling describe-scaling-activities \
  --service-namespace ecs \
  --resource-id "service/$(terraform output -raw cluster_name)/web-api-cluster-web-svc"

terraform destroy -var="container_image=..." -var="secret_arn=..." -var="vpc_id=..." \
  -var='public_subnets=["..."]' -var='private_subnets=["..."]'
✓ Expected Output After Apply
Apply complete! Resources: 16 added, 0 changed, 0 destroyed.

Outputs:

alb_dns_name = "web-api-cluster-alb-1234567890.us-east-1.elb.amazonaws.com"
cluster_name = "web-api-cluster"
service_arn  = "arn:aws:ecs:us-east-1:123456789012:service/web-api-cluster/web-api-cluster-web-svc"