← All posts
ECS

ECS native blue/green deployments with CloudFormation: how it actually works

Two target groups swapping the live role during an ECS native blue/green deployment driven by CloudFormation

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-a and web-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: ROLLING with MaximumPercent: 100 and MinimumHealthyPercent: 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.Arn

There 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:

  • AlternateTargetGroupArn is required in CloudFormation but not in the API. The CloudFormation reference marks it Required: Yes, while the ECS API reference for the same structure says Required: 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: 0

We 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.

note A side effect to keep in mind: ECS changes rule weights to a 100 scale when it rewrites them, so the live rules read 0/100 against the template’s 1/0. Drift detection reports both rules as modified permanently, even straight after a clean deployment. It’s just normal background noise. Write it down so that no one else changes it.

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):

ElapsedWhat happened
00:00Stack update issued
00:30First green task started
02:45Test listener starts serving the green revision
03:13Production traffic shifts to green - the bake phase starts
13:33Blue tasks stopped - the 10-minute bake phase is over
14:43ECS declares the deployment complete, service reaches steady state
15:19Stack 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 DeploymentConfiguration are 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.

Let's talk

Start with a free second opinion: 30 minutes with our founder. No account access needed, and you keep a short written note of what we covered.

Schedule a meeting: our calendar