Dual Target Group Registration for ECS Fargate
how to register the same ECS service with two independent target groups simultaneously — the CDK implementation, security group requirements, and lifecycle mechanics that make zero-downtime ALB migrations work.
- DATE:
- JUN.22.2025
- READ:
- 14 MIN
Migrating traffic from one load balancer to another is one of the riskiest infrastructure changes you can make. DNS cutover timing, connection draining, health check states — there are a dozen ways it can go wrong. But what if your ECS tasks could serve traffic from both load balancers simultaneously?
That’s exactly what dual target group registration gives you: a single ECS Fargate service registered with two independent target groups, each behind its own ALB, handling traffic at the same time with zero downtime. This post covers the CDK implementation, how ECS manages dual registration, the security group requirements, and the lifecycle mechanics you need to understand.
The scenario
Our ECS Fargate services were originally deployed using ApplicationLoadBalancedFargateService — the high-level CDK construct that creates an ALB, target group, listener, and ECS service in one shot. Each service had its own public-facing ALB.
We needed to consolidate these behind a single shared internal ALB fronted by API Gateway. But we couldn’t simply switch over — the existing ALBs were serving production traffic through DNS records and couldn’t be removed until the new path was fully validated.
The solution: register the same ECS tasks with both the original target group and a new target group attached to the shared ALB’s listener. Both paths work simultaneously. Traffic flows through either path. If the new path has issues, the old path is completely unaffected.
The CDK implementation
Step 1: The original service (already deployed)
from aws_cdk import (
Stack,
aws_ecs as ecs,
aws_ecs_patterns as ecs_patterns,
aws_ec2 as ec2,
aws_elasticloadbalancingv2 as elbv2,
)
class BackendStackQA(Stack):
def __init__(self, scope, construct_id, **kwargs):
super().__init__(scope, construct_id, **kwargs)
vpc = ec2.Vpc(self, "VpcQA", max_azs=2)
cluster = ecs.Cluster(self, "ClusterQA", vpc=vpc)
fargate_service = ecs_patterns.ApplicationLoadBalancedFargateService(
self, "BackendServiceQA",
cluster=cluster,
cpu=512,
memory_limit_mib=1024,
desired_count=2,
task_image_options=ecs_patterns.ApplicationLoadBalancedTaskImageOptions(
image=ecs.ContainerImage.from_ecr_repository(ecr_repo, tag="qa"),
container_port=8000,
),
public_load_balancer=True,
)Step 2: Add the second target group
The key method is service.attach_to_application_target_group(). This registers the existing ECS service with a new target group without modifying the original:
from utils import get_shared_alb_listener_arn, get_shared_alb_sg_id
shared_listener_arn = get_shared_alb_listener_arn(self)
shared_alb_sg_id = get_shared_alb_sg_id(self)
shared_alb_sg = ec2.SecurityGroup.from_security_group_id(
self, "SharedAlbSg", shared_alb_sg_id,
)
# Create a new target group for the shared ALB
backend_tg = elbv2.ApplicationTargetGroup(
self, "BackendSharedTg",
vpc=vpc,
port=8000,
protocol=elbv2.ApplicationProtocol.HTTP,
target_type=elbv2.TargetType.IP,
health_check=elbv2.HealthCheck(
path="/api/health/",
port="8000",
healthy_http_codes="200",
interval=Duration.seconds(30),
timeout=Duration.seconds(10),
healthy_threshold_count=2,
unhealthy_threshold_count=3,
),
)
# Register the existing ECS service with the new target group
fargate_service.service.attach_to_application_target_group(backend_tg)
# CRITICAL: Allow the shared ALB to reach the ECS tasks
fargate_service.service.connections.allow_from(
shared_alb_sg,
ec2.Port.tcp(8000),
"Shared ALB to backend",
)
# Attach the target group to the shared ALB listener via a rule
shared_listener = elbv2.ApplicationListener.from_application_listener_attributes(
self, "ImportedSharedListener",
listener_arn=shared_listener_arn,
security_group=shared_alb_sg,
)
elbv2.ApplicationListenerRule(
self, "BackendQaRoutingRule",
listener=shared_listener,
priority=100,
conditions=[
elbv2.ListenerCondition.path_patterns(["/api/qa/*"]),
],
target_groups=[backend_tg],
)When you deploy this, CloudFormation creates the new target group, updates the ECS service to include it in its loadBalancers configuration, creates the security group rule, and creates the listener rule. The ECS service update triggers a rolling deployment where new tasks are registered in both target groups, and old tasks are deregistered from both.
How ECS manages dual registration
Independent health checks
Each target group runs its own health checks independently:
- The original target group might check
/healthevery 15 seconds - The shared target group might check
/api/health/every 30 seconds - A task can be healthy in one target group and unhealthy in another
This independence is important. If the shared ALB’s health check path is misconfigured, tasks will be marked unhealthy in the shared target group but continue serving traffic through the original ALB. Your existing traffic is never disrupted by the new target group’s configuration.
Task registration lifecycle
When ECS launches a new task:
- Task starts and gets an ENI with a private IP
- ECS registers the task IP with every target group in the service’s
loadBalancerslist - Each target group begins its own independent health check sequence
- The task becomes healthy in each target group independently
When ECS drains a task (during deployment or scale-in):
- ECS marks the task as
DRAININGin all target groups simultaneously - Each target group starts its own deregistration delay (default: 300 seconds)
- The task is removed from each target group after its respective delay
- ECS stops the task only after all target groups have deregistered it
Under the hood: the loadBalancers array
The ECS service’s loadBalancers configuration contains an entry per target group:
{
"loadBalancers": [
{
"targetGroupArn": "arn:aws:...original-tg...",
"containerName": "web",
"containerPort": 8000
},
{
"targetGroupArn": "arn:aws:...shared-tg...",
"containerName": "web",
"containerPort": 8000
}
]
}Both entries reference the same container and port. ECS uses this to know which target groups to register each task with.
Security group requirements
This is where most implementations break. When you have a single ECS service behind two ALBs, the service’s security group must allow ingress from both ALB security groups.
The ApplicationLoadBalancedFargateService construct automatically creates an ingress rule from the original ALB’s security group. But the shared ALB’s security group is managed by the shared infrastructure stack. You must explicitly add the rule:
# This is what attach_to_application_target_group does NOT do automatically
# when the ALB is in a different stack
fargate_service.service.connections.allow_from(
shared_alb_sg,
ec2.Port.tcp(8000),
"Shared ALB to backend",
)Without this rule, the shared ALB’s health checks fail — packets never reach the task — and all targets show as unhealthy. The original ALB continues working fine.
Debugging security group issues
When targets show as unhealthy in only one target group, check security groups first:
# Get the ECS service's security group
aws ecs describe-services
--cluster ClusterQA
--services BackendServiceQA
--query "services[0].networkConfiguration.awsvpcConfiguration.securityGroups"
# Check its ingress rules — you should see two rules on port 8000
aws ec2 describe-security-groups
--group-ids sg-ecs-service-id
--query "SecurityGroups[0].IpPermissions"Limits and considerations
Five target groups per service — ECS’s hard limit. Cannot be increased. In practice, two or three (original ALB + shared ALB, possibly an NLB for gRPC) is common.
Health check alignment matters. Longer health check intervals or higher thresholds slow down rolling deployments. Optimize shared target group health checks:
health_check=elbv2.HealthCheck(
interval=Duration.seconds(15),
timeout=Duration.seconds(5),
healthy_threshold_count=2,
unhealthy_threshold_count=2,
)IP target type is required for Fargate. Fargate uses awsvpc networking — each task gets its own ENI. Target groups must use IP target type, not INSTANCE. The L3 construct handles this for the original target group, but you must set it explicitly for additional groups:
backend_tg = elbv2.ApplicationTargetGroup(
self, "BackendSharedTg",
target_type=elbv2.TargetType.IP, # Required for Fargate
...
)Beyond migration: other use cases
Blue/green deployments
Register the green ECS service with a separate target group. Use weighted forwarding to gradually shift traffic:
elbv2.ApplicationListenerRule(
self, "GreenWeightedRule",
listener=shared_listener,
priority=90,
conditions=[elbv2.ListenerCondition.path_patterns(["/api/*"])],
action=elbv2.ListenerAction.weighted_forward(
target_groups=[
elbv2.WeightedTargetGroup(target_group=blue_tg, weight=90),
elbv2.WeightedTargetGroup(target_group=green_tg, weight=10),
],
),
)A/B testing
Register the same service with two ALBs serving different audiences. The public ALB handles customer traffic; an internal ALB handles QA traffic with debug headers enabled.
Multi-protocol support
Register the same service with an ALB target group (HTTP) and an NLB target group (TCP). This allows the service to receive both HTTP traffic through API Gateway and raw TCP connections for WebSocket or gRPC.
Removing the original target group
Once the new path is validated and DNS has cut over, remove the original ALB. But you can’t simply delete the ApplicationLoadBalancedFargateService construct — it owns the ECS service itself.
Options:
Migrate the stack to use a standalone
ecs.FargateServicewith only the shared target group, usingRemovalPolicy.RETAINon the original ALB resources during transition, then delete the retained resources manually.If you used dual registration from the start, simply remove the original target group attachment and the original ALB stack. The ECS service continues serving through the shared target group without interruption.
Monitor both target groups with UnHealthyHostCount alarms before making any removal decisions:
backend_tg.metric_unhealthy_host_count().create_alarm(
self, "SharedTgUnhealthyHosts",
evaluation_periods=2,
threshold=1,
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
)