Production ECS Fargate Service: ALB, Auto Scaling, and Secrets Manager
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.
- 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=["..."]'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"

