
In July 2025, ECS introduced a native blue/green deployment type
comprising two target groups, a test listener, a bake period and managed rollback, all of which are run by ECS itself
with no involvement from CodeDeploy. The documentation covers the ideal scenario. However, it says much less about how
the feature behaves with CloudFormation on top, such as where UPDATE_COMPLETE
lands relative to the bake, how long each stage really takes and whether stack updates end up conflicting with the
listener rules that the feature rewrites. We built a small test stack and ran the machinery repeatedly, documenting
everything. This post opens a series on how ECS deployment strategies behave when driven by CloudFormation - every
number in it comes from a measured run.
The test stack
Everything in this series was measured on one small CloudFormation stack consisting of three Fargate services at the smallest available size (256 CPU units and 512 MB of memory) running the same test container.
- web - two tasks,
Strategy: BLUE_GREEN, behind an ALB. Two target groups (web-aandweb-b), a production listener on port 80 and a test listener on port 8080. This post is about this service. - worker - two tasks,
Strategy: ROLLING, no load balancer. - singleton - one task,
Strategy: ROLLINGwithMaximumPercent: 100andMinimumHealthyPercent: 0, the configuration a hard singleton forces (celery beat is the classic example).
The worker and the singleton will be covered in more detail in later posts in the series. ECS also accepts LINEAR and CANARY strategies, but we did not test these, so the series sticks to what we have measured: ROLLING and BLUE_GREEN.
The test container is short enough to paste in full and is easier to understand than a description. It is a Python http.server whose behaviour is switched by a MODE environment variable.
1import http.server, os, signal, sys, time
2
3MODE = os.environ.get("MODE", "ok") # ok | unhealthy | error5xx | crash
4VERSION = os.environ.get("VERSION", "v1")
5
6# PID 1 in the container: without a handler SIGTERM is ignored and every ECS stop
7# escalates to SIGKILL (exit 137) after the 30s stop timeout
8signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
9
10if MODE == "crash":
11 time.sleep(5)
12 sys.exit(1)
13
14class H(http.server.BaseHTTPRequestHandler):
15 def do_GET(self):
16 if self.path == "/health":
17 code = 500 if MODE == "unhealthy" else 200
18 else:
19 # error5xx: fail half of ordinary requests, health stays green
20 code = 500 if (MODE == "error5xx" and int(time.time() * 10) % 2) else 200
21 self.send_response(code)
22 self.send_header("Content-Type", "text/plain")
23 self.end_headers()
24 self.wfile.write(f"{VERSION} {MODE} {code}\n".encode())
25
26http.server.ThreadingHTTPServer(("", 8080), H).serve_forever()ok answers everything with a 200 status code. unhealthy only fails the health endpoint. error5xx
fails roughly half of the real requests, but the health endpoint stays green. crash exits five seconds after starting. The SIGTERM handler ensures that ordinary stops are read as clean exits (143) rather than the force-kill signature (137). Between them, these four modes simulate most of the ways in which a release can go wrong.
Deployments are triggered in the same way as a CI pipeline: a stack update that flips an image tag parameter between two identical images that differ only in tag. During every run, we kept witnesses going: curl loops against both listeners at one request per second; a merged stream of ECS service events, stack events, and alarm state changes; before-and-after snapshots of rules, targets, and services; and probes of the deployment record via describe-service-deployments.
The moving parts
The native blue/green feature runs on the standard ECS deployment controller. If you find yourself configuring the
CODE_DEPLOY controller, you are using the old feature. The new machinery is located in two places on the service
resource. The DeploymentConfiguration picks the strategy and bake time, while an AdvancedConfiguration block on
the load balancer entry sets up the second target group and listener rules that ECS is allowed to rewrite.
1WebService:
2 Type: AWS::ECS::Service
3 Properties:
4 ServiceName: web
5 Cluster: !Ref Cluster
6 LaunchType: FARGATE
7 DesiredCount: 2
8 TaskDefinition: !Ref WebTaskDef
9 DeploymentController:
10 Type: ECS
11 DeploymentConfiguration:
12 Strategy: BLUE_GREEN
13 BakeTimeInMinutes: 10
14 LoadBalancers:
15 - ContainerName: web
16 ContainerPort: 8080
17 TargetGroupArn: !Ref TgA
18 AdvancedConfiguration:
19 AlternateTargetGroupArn: !Ref TgB
20 ProductionListenerRule: !Ref ProdRule
21 TestListenerRule: !Ref TestRule
22 RoleArn: !GetAtt EcsBgRole.ArnThere are four properties involved. AlternateTargetGroupArn points at the second target group. New tasks are
assigned to the group that isn’t currently live, and the two groups swap roles every time there’s a deployment.
ProductionListenerRule and TestListenerRule point at the ALB listener rules that ECS rewrites during the traffic
shifts. ECS uses the RoleArn to access the Elastic Load Balancing APIs, and AWS provides a managed policy
for it.
1EcsBgRole:
2 Type: AWS::IAM::Role
3 Properties:
4 AssumeRolePolicyDocument:
5 Version: "2012-10-17"
6 Statement:
7 - Effect: Allow
8 Principal:
9 Service: ecs.amazonaws.com
10 Action: sts:AssumeRole
11 ManagedPolicyArns: [arn:aws:iam::aws:policy/AmazonECSInfrastructureRolePolicyForLoadBalancers]ECS keeps old tasks running for a certain amount of time after production traffic has shifted. BakeTimeInMinutes
controls this window - the time when a rollback is a rule flip rather than a redeploy. If you don’t set it,
it defaults to 15 minutes
for blue/green. This is the last point where the deployment can still be rolled back.
Two documentation traps before you start:
AlternateTargetGroupArnis required in CloudFormation but not in the API. The CloudFormation reference marks itRequired: Yes, while the ECS API reference for the same structure saysRequired: No. If you are reading the API docs while writing a template, you will conclude the property is optional and CloudFormation will tell you otherwise. In practice you want it anyway - the whole feature depends on the alternate target group.- There is an older feature with a similar name in CloudFormation. The “About blue/green deployments” page of the User Guide describes a CloudFormation mechanism from years earlier that was based on CodeDeploy - a transform, hooks, a different controller. Nothing on that page relates to the native feature we’re discussing here, and it ranks well in search results. Check which machinery a page is describing before following it.
The wiring rule that cost us half a day
Every listener rule involved in the swap must be a weighted forward over both target groups, with the live group at weight 1 and the alternate at weight 0:
1ProdRule:
2 Type: AWS::ElasticLoadBalancingV2::ListenerRule
3 Properties:
4 ListenerArn: !Ref ProdListener
5 Priority: 3
6 Conditions:
7 - Field: path-pattern
8 Values: ["/*"]
9 Actions:
10 - Type: forward
11 ForwardConfig:
12 TargetGroups:
13 - TargetGroupArn: !Ref TgA
14 Weight: 1
15 - TargetGroupArn: !Ref TgB
16 Weight: 0We learned this when we first built it. The obvious wiring (both rules forwarding to the live group only) means that the alternate group is referenced by no rule. A target group not assigned to a listener is never health checked by the ALB. ECS registers new tasks into both groups, waits for health checks that will never run, and its scheduler then stops every task whose alternate-group health never goes green. The stopped tasks all have the same reason listed:
Task failed ELB health checks in (target-group .../web-b)The service never stabilises, and the error message does not point to the listener rules. The 0-weight entry is there so that the alternate group is linked to the ALB. Its health checks start immediately. It receives no traffic. The ECS console generates this same pattern when you build a blue/green service, and that’s how we found the fix.
What a deployment actually looks like, measured
Here is a full CloudFormation-driven blue/green, measured end to end with a 10-minute bake (July 2026, eu-west-1). Times are elapsed from the moment the stack update was started (keep in mind container start time and number of containers you launch will impact this - our small test app started quickly and we used two tasks in the service):
| Elapsed | What happened |
|---|---|
| 00:00 | Stack update issued |
| 00:30 | First green task started |
| 02:45 | Test listener starts serving the green revision |
| 03:13 | Production traffic shifts to green - the bake phase starts |
| 13:33 | Blue tasks stopped - the 10-minute bake phase is over |
| 14:43 | ECS declares the deployment complete, service reaches steady state |
| 15:19 | Stack reaches UPDATE_COMPLETE |
Two observations from the table. First, the fixed overhead is about 5 minutes: roughly three of them getting green up, health checked and through the traffic shifts at the front, and about two of cleanup and CloudFormation tail at the back. The total deployment time is about five minutes, plus the bake time. Second, the production shift is a single step. The documentation is explicit that for blue/green this is “an immediate (all-at-once) shift” of 100% of the traffic - there is no gradual ramp unless you pick the linear or canary strategies (which are a bit different from standard blue/green).
The same page names 11 lifecycle stages that a deployment moves through: RECONCILE_SERVICE
(only when a deployment starts with more than one service revision active), PRE_SCALE_UP,
SCALE_UP, POST_SCALE_UP, TEST_TRAFFIC_SHIFT, POST_TEST_TRAFFIC_SHIFT,
PRE_PRODUCTION_TRAFFIC_SHIFT, PRODUCTION_TRAFFIC_SHIFT, POST_PRODUCTION_TRAFFIC_SHIFT,
BAKE_TIME and CLEAN_UP. Lifecycle hooks are attached to stages. Failures report the stage they happened in.
The deployment record shows the current stage during deployment. These terms are explained in more detail in the rest
of this series.
The 28-second window
Look at the gap between the test listener going green (02:45) and the production shift (03:13). That is the entire period in which the new revision is reachable on the test listener while production still runs the old one - the natural moment for a smoke test. Without a pause hook, it lasted 26 to 28 seconds in every run we measured (four runs, July 2026, eu-west-1). ECS does not wait to see whether anyone wants to test the green tasks. The moment they are healthy and test traffic has shifted, it proceeds.
No realistic smoke suite fits in 28 seconds. It is barely enough time for a human to switch windows. If you want a real smoke test phase - the deployment stopping and waiting while your pipeline runs checks against the test listener - that is what pause lifecycle hooks are for, and they are the subject of the next post in this series: “ECS pause hooks: a real smoke test phase, and where they do nothing”.
Where UPDATE_COMPLETE lands
The question that determines the design of your pipeline: when CloudFormation says the update is complete,
what is actually true? In the timed run, the stack reached UPDATE_COMPLETE 36 seconds after ECS declared
the deployment complete, after the bake had run its course and the blue tasks had been stopped. So, the CloudFormation
waiter means what you hope it means (it waits). When it returns, there are no old-code tasks. A pipeline step that runs after
the stack update, such as a database migration that requires the new code everywhere or a cache invalidation, can
rely on the waiter alone with no additional ECS polling.
Conversely, the stack holds UPDATE_IN_PROGRESS throughout the entire bake and CloudFormation only accepts a new update
when the stack is in a *_COMPLETE state. A second deployment issued mid-bake is rejected outright. Nothing queues
inside CloudFormation - back-to-back deployments need to be queued in your CI or fail. With a 10-minute bake, each web
deployment holds the deployment lock for around 15 minutes, so your pipeline design should take this into account.
Does CloudFormation fight ECS’s swapped state?
This is what made us decide to build the stack in the first place. After one deployment, ECS has changed the
listener rules that were created by CloudFormation. The live target group is now different from the one named in
the service’s TargetGroupArn. Two systems now have different ideas about the same resources - the classic setup for
a drift war, where every later stack update reverts what ECS did and breaks traffic.
It doesn’t happen. We ran three CloudFormation-driven blue/greens in a row (on a 1-minute bake, taking four and a half to six minutes each), with an unrelated stack update (a tag change on the cluster) in between. That update took 9 seconds and didn’t produce any ECS activity: no deployment, no task replaced, and the rules were the same before and after. In total, the curl loops made 2,255 requests (1,136 to the production listener and 1,119 to the test listener). Every one of these requests was answered with a 200.
Conclusion: CloudFormation doesn’t change ECS-rewritten listener rules unless the rule resource is in the change set.
Two behaviours around the service resource are worth knowing, both July 2026 observations rather than documented contract:
- Any change touching the service resource runs a full blue/green cycle. Even a change to the task subnets, with no new image, does the whole dance - green tasks, test shift, production shift, bake, cleanup.
- Changes confined to
DeploymentConfigurationare the exception. Attaching deployment alarms, attaching a circuit breaker, flipping its rollback flag, adding or removing a lifecycle hook - each was applied as a quiet in-place configuration update, under a minute, with no deployment record created and no task replaced. We saw this four separate times while switching features on and off between test runs. It means you can tune the safety machinery on a production service without triggering a deployment.
What it costs, and what you get
The price of the machinery is modest and predictable: about 5 minutes of fixed overhead plus whatever bake you choose, two target groups and one extra listener in the template, and a wiring rule you now know in advance. In exchange, production traffic moves straight onto tasks that passed health checks before a request reached them. The old version is kept running through the bake phase in case you need it, and the stack’s completion signal really means the deployment is finished.
What this post has not covered is failure, and that is where the machinery matters most. In our failure tests, deployments were repeatedly rolled back before production traffic ever touched the bad release. Those runs are the rest of the series: what the circuit breaker actually does and who owns the rollback when CloudFormation is driving (“ECS circuit breaker and CloudFormation: who owns the rollback”), what deployment alarms really promise (“ECS deployment alarms: the fine print”), and the one that surprised us most, a crash-looping release that every layer marked successful (“The deployment that lied: ECS singletons and false success”).
Building deployment pipelines that behave this predictably is part of our DevOps and infrastructure as code work - if your ECS deployments are less boring than they should be, we can help.