Hands-On Labs · The Capstone · Part 3 of 6

Capstone Part 3 — Deployment Strategy

This is the third of six parts building one continuous project: shipping checkout-svc safely. Part 1 gave it a pipeline that gates every push; Part 2 gave it three real environments, each behind its own Application Load Balancer, provisioned from one reusable Terraform module. Every deploy since Part 2 has meant one thing: point prod's single target group at a new image tag and let the Auto Scaling Group cycle through it, all at once, whether or not the new build was actually safe. Today that changes. This page adds a second target group to prod's existing ALB, wires a weighted rollout into the exact pipeline Part 1 built, and gates every traffic shift behind a CloudWatch alarm that watches only the build under test — not the blended, fleet-wide number. By the end, a bad build never receives more than 10% of prod's traffic before it is rolled back automatically, with nobody watching a dashboard in real time to catch it.

☺ Explain it like I'm 10

Picture a grocery store testing a brand-new self-checkout machine. You don't rip out every register and swap in the new one overnight — you open just one extra lane, quietly send it one out of every ten customers, and post someone nearby watching for jammed receipts or wrong totals. If a few minutes pass with nothing wrong, you send more customers its way — half, then everyone. But if even one receipt jams, that lane closes immediately and every customer goes back to the registers you already trust, before more than a small, bounded handful of people ever touched the broken one. Nobody has to be standing there watching for it to work — the store's own alarm does the watching.

🐢🐘Your hosts for this part: Timmy the Turtle & Ellie the Elephant — Timmy refuses to let an unverified build past 10% of production traffic, and Ellie keeps the record of exactly which image tag is live on which target group at every step of the rollout.
⚠ Where you are arriving from, and where you're headed

Arriving: checkout-svc running behind its own ALB and Auto Scaling Group in dev, staging, and prod, all provisioned from Part 2's modules/checkout-service module — one target group per environment, one image tag at a time, a full-fleet cutover on every deploy. Leaving this page: a second target group and a small canary Auto Scaling Group behind prod's existing ALB, a weighted aws_lb_listener_rule splitting live traffic between them, a CloudWatch alarm scoped to the canary alone, and a deploy-canary job wired into Part 1's pipeline that shifts that weight in steps — 10% → 50% → 100% — pausing at each one to let the alarm decide whether to continue or to roll back on its own. Part 4 picks up exactly here and gives you real dashboards for the alarm you're about to wire almost blind.

What this part assumes, and what it produces

☺ Like you're 10: The store, the registers, and the two-lane trick — nothing new to install, just the AWS account and the repo Parts 1 and 2 already built.

This part assumes Part 2's three environments already exist and are healthy — specifically prod, running checkout-svc:7f3a9c2 behind module.checkout's ALB and Auto Scaling Group. You need the same AWS CLI and Terraform 1.9+ from Part 2, plus jq for the JSON the script below leans on. Nothing here touches dev or staging — a deployment strategy is a prod-specific concern, because dev and staging's whole job is catching a bad build before it ever reaches an environment where blast radius matters; see deployment strategies for why the strategy you pick is really a statement about how much exposure you can tolerate at once, and how fast you can undo it. What you're about to build is deliberately a hybrid of the two named strategies on that page, not a pure pick of one: a canary-style weighted, gradual traffic shift is the day-to-day promotion mechanism, riding on top of a blue-green-style parked idle fallback — the old fleet stays fully provisioned and one API call from serving 100% again, exactly like blue-green promises, for as long as the new fleet hasn't been proven under a full bake.

The world model this part adds

Building on Part 2's infrastructure, here's what gets born on this page, all of it scoped to prod alone:

ThingNameIntroduced
The applicationcheckout-svcPart 1
Live image (blue)registry.internal/checkout-svc:7f3a9c2Part 1–2
Build under test (canary)registry.internal/checkout-svc:a18e4f0 — this page's worked examplePart 3 — this page
Canary target group + ASGcheckout-prod-canary, idle at 0 instances between releasesPart 3 — this page
Weighted listener ruleaws_lb_listener_rule.canary_split, priority 100 on prod's existing ALBPart 3 — this page
Canary-scoped alarmcheckout-prod-canary-5xxAWS/ApplicationELB, keyed to the canary's own TargetGroup dimensionPart 3 — this page
Rollout controllerscripts/promote-canary.sh, run by the deploy-canary job in Part 1's ci.ymlPart 3 — this page

Keep that table in mind for the rest of this capstone: Part 4 replaces today's one narrow alarm with real dashboards on both target groups, and Part 6 comes back to tighten the canary launch template the same way it tightens blue's.

Standing up the canary fleet: a second target group behind the same ALB

☺ Like you're 10: A second lane, built next to the first one, borrowing the same building and the same guard rules — not a whole new store.

Part 2's module never had to hand out its listener or its own security group — nothing needed to attach to them from outside the module. Today's second fleet does, so the module gets three small, purely additive outputs before anything else changes. Add these to the bottom of infra/modules/checkout-service/outputs.tf; nothing already in the module is touched:

## infra/modules/checkout-service/outputs.tf — three lines added in Part 3

output "listener_arn"          { value = aws_lb_listener.checkout.arn }
output "app_security_group_id" { value = aws_security_group.app.id }
output "alb_arn_suffix"        { value = aws_lb.checkout.arn_suffix }

With those in place, add a new file — infra/envs/prod/canary.tf — that stands up the canary fleet beside Part 2's existing prod resources, reusing its ALB, its app security group, and its AMI lookup rather than duplicating a whole new environment:

## infra/envs/prod/canary.tf — new in Part 3; nothing above this file changes

variable "canary_image_tag" { type = string }   # e.g. "a18e4f0" — the build under test

data "aws_vpc" "default" { default = true }
data "aws_subnets" "default" {
  filter { name = "vpc-id", values = [data.aws_vpc.default.id] }
}
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter { name = "name", values = ["al2023-ami-*-x86_64"] }
}

resource "aws_lb_target_group" "checkout_canary" {
  name     = "checkout-prod-canary"
  port     = 8080
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id
  health_check { path = "/healthz", healthy_threshold = 2, unhealthy_threshold = 3, interval = 15 }
}

resource "aws_launch_template" "checkout_canary" {
  name_prefix             = "checkout-prod-canary-"
  image_id                = data.aws_ami.al2023.id
  instance_type           = "m5.large"                              # match blue's size, not dev's t3.micro
  vpc_security_group_ids  = [module.checkout.app_security_group_id] # reuse blue's app SG — same ALB, same rules
  user_data = base64encode(<<-EOT
    #!/bin/bash
    dnf install -y docker && systemctl enable --now docker
    docker run -d --restart unless-stopped -p 8080:8080 \
      registry.internal/checkout-svc:${var.canary_image_tag}
  EOT
  )
  tag_specifications {
    resource_type = "instance"
    tags          = { Name = "checkout-prod-canary", Environment = "prod-canary" }
  }
}

resource "aws_autoscaling_group" "checkout_canary" {
  name                = "checkout-prod-canary"
  vpc_zone_identifier = data.aws_subnets.default.ids
  target_group_arns   = [aws_lb_target_group.checkout_canary.arn]
  health_check_type   = "ELB"
  min_size            = 0
  max_size            = 6                # room to match blue's full size on a successful promotion
  desired_capacity    = 0                # idle between releases — the pipeline wakes it when a rollout starts
  launch_template { id = aws_launch_template.checkout_canary.id, version = "$Latest" }
  tag { key = "Name", value = "checkout-prod-canary", propagate_at_launch = true }
}

Run it the same reviewed way Part 2 taught — terraform plan -out=tfplan -var-file=prod.tfvars -var canary_image_tag=a18e4f0, read every line, then terraform apply tfplan. Notice what this apply does not do: it never touches module.checkout's block, blue's ASG, or blue's target group at all — the diff is additive only, exactly the property infrastructure as code promises when a module boundary is drawn correctly.

The weighted listener rule, and the alarm that watches only the canary

☺ Like you're 10: A dial that decides what percentage of customers walk into which lane, plus a guard whose whole job is watching the new lane and nobody else's.

Two more resources, in the same canary.tf file, finish the picture: the rule that splits live traffic by percentage, and the alarm that decides whether that percentage is allowed to grow.

resource "aws_lb_listener_rule" "canary_split" {
  listener_arn = module.checkout.listener_arn
  priority     = 100
  action {
    type = "forward"
    forward {
      target_group { arn = module.checkout.target_group_arn,        weight = 100 }
      target_group { arn = aws_lb_target_group.checkout_canary.arn, weight = 0   }
      stickiness { enabled = false, duration = 1 }   # weighted split, not session-pinned
    }
  }
  condition { path_pattern { values = ["/*"] } }
}

resource "aws_cloudwatch_metric_alarm" "canary_5xx" {
  alarm_name          = "checkout-prod-canary-5xx"
  namespace           = "AWS/ApplicationELB"
  metric_name         = "HTTPCode_Target_5XX_Count"
  statistic           = "Sum"
  period              = 60
  evaluation_periods  = 2
  threshold           = 5
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"
  dimensions = {
    LoadBalancer = module.checkout.alb_arn_suffix
    TargetGroup  = aws_lb_target_group.checkout_canary.arn_suffix
  }
}

output "canary_rule_arn"         { value = aws_lb_listener_rule.canary_split.arn }
output "canary_target_group_arn" { value = aws_lb_target_group.checkout_canary.arn }
output "blue_target_group_arn"   { value = module.checkout.target_group_arn }

The rule starts at weight = 100 for blue and weight = 0 for canary the moment it's created — it exists, but sends nobody to the new fleet until a rollout deliberately says otherwise. The alarm's dimensions block is the detail worth slowing down on: it names both LoadBalancer and TargetGroup, which scopes HTTPCode_Target_5XX_Count to the canary's own instances only. Leave off the TargetGroup dimension and you get the ALB's blended, fleet-wide 5xx count instead — a canary erroring on 100% of its own small slice of traffic barely moves a number that's 90%+ healthy blue underneath it, and the alarm that's supposed to catch a bad build quietly never fires.

ALB listener (prod) weighted listener rule blue target group checkout-prod canary target group checkout-prod-canary 90% 10% ASG checkout-prod 3–6 instances ASG checkout-prod-canary 0–6, idle at rest ⏱ CloudWatch alarm canary TargetGroup 5xx only trips → rollback: rule → 100% blue, canary ASG → 0 holds → promote: advance to next weight step stickiness disabled — every request re-rolls the weighted split, nobody's pinned to a lane

Wiring the rollout into Part 1's pipeline

☺ Like you're 10: The same inspector from Part 1, now also holding the dial that decides how many customers walk into the new lane.

Everything above is infrastructure — reviewed, applied once, sitting idle until a release actually starts. The rollout itself is a runtime operation, not an infrastructure change, so it belongs in a script the pipeline calls, not in another terraform apply. Add a job to the end of Part 1's .github/workflows/ci.yml:

## .github/workflows/ci.yml — deploy-canary job, appended in Part 3
  deploy-canary:
    needs: build-and-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: prod              # requires a reviewer's approval before this job even starts
    permissions:
      id-token: write              # OIDC — no long-lived AWS keys stored in the repo
      contents: read
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/checkout-svc-deploy
          aws-region: us-east-1

      - uses: hashicorp/setup-terraform@v3
        with: { terraform_version: "1.9.8" }

      - name: Provision this build's canary launch template
        working-directory: infra/envs/prod
        run: |
          terraform init
          terraform apply -auto-approve -var-file=prod.tfvars \
            -var canary_image_tag=${{ github.sha }}

      - name: Run the weighted rollout
        run: bash scripts/promote-canary.sh

The environment: prod line uses GitHub's own environment protection rules — a named reviewer has to approve the job before it starts, the same manual gate Part 2 put in front of a raw terraform apply against prod. Once approved, this job first applies only the canary launch template for ${{ github.sha }} — blue's variables are untouched, so that apply's diff is exactly the one resource change it needs to be — then hands off to the script that actually moves traffic:

#!/usr/bin/env bash
## scripts/promote-canary.sh — the runtime half; no terraform apply anywhere below this line
set -euo pipefail

RULE_ARN=$(terraform -chdir=infra/envs/prod output -raw canary_rule_arn)
BLUE_TG=$(terraform -chdir=infra/envs/prod output -raw blue_target_group_arn)
GREEN_TG=$(terraform -chdir=infra/envs/prod output -raw canary_target_group_arn)
GREEN_ASG=checkout-prod-canary
BLUE_ASG=checkout-prod
ALARM=checkout-prod-canary-5xx
BAKE=180
STEPS=(10 50 100)

set_weight() {   # $1 = canary's percentage
  local green=$1 blue=$((100 - green))
  aws elbv2 modify-rule --rule-arn "$RULE_ARN" --actions '[{
    "Type":"forward","ForwardConfig":{"TargetGroups":[
      {"TargetGroupArn":"'"$BLUE_TG"'","Weight":'"$blue"'},
      {"TargetGroupArn":"'"$GREEN_TG"'","Weight":'"$green"'}]}}]' >/dev/null
  echo "traffic split -> blue ${blue}% / canary ${green}%"
}

alarm_in_alarm() {
  [ "$(aws cloudwatch describe-alarms --alarm-names "$ALARM" \
       --query 'MetricAlarms[0].StateValue' --output text)" = "ALARM" ]
}

rollback() {
  echo "canary alarm tripped -- rolling back to 100% blue"
  set_weight 0
  aws autoscaling update-auto-scaling-group --auto-scaling-group-name "$GREEN_ASG" --desired-capacity 0
  exit 1   # fail the job -- a red deploy-canary run is the point, not a bug
}

echo "waking the canary fleet..."
aws autoscaling update-auto-scaling-group --auto-scaling-group-name "$GREEN_ASG" --desired-capacity 1
until aws elbv2 describe-target-health --target-group-arn "$GREEN_TG" \
      --query 'TargetHealthDescriptions[0].TargetHealth.State' --output text 2>/dev/null | grep -q healthy; do
  sleep 10
done

for pct in "${STEPS[@]}"; do
  set_weight "$pct"
  echo "baking ${BAKE}s at canary=${pct}%..."
  sleep "$BAKE"
  alarm_in_alarm && rollback
done

echo "held through every step -- canary is now serving 100% of prod traffic"
echo "parking blue as the instant-rollback fallback, not deleting it"
aws autoscaling update-auto-scaling-group --auto-scaling-group-name "$BLUE_ASG" \
  --min-size 0 --desired-capacity 0
⚠ Don't drive the live weight shifts through terraform apply

It's tempting to make each step above just another terraform apply -var canary_weight=10, since Terraform already owns the listener rule. Resist it: apply is a full plan-against-locked-state cycle, built for reviewed infrastructure changes, not a loop that needs to move every 60–180 seconds. Running it that often holds the DynamoDB lock repeatedly, is far slower than the bake window needs, and risks colliding with a genuinely different infrastructure change some other CI run is applying to the same prod state at the same time. Terraform's job stops at creating the rule; a runtime script talking straight to elbv2 modify-rule is what should be allowed to move it every few minutes.

◆ Key idea

Blue is never deleted mid-rollout — only ever scaled down, and only after canary has held at 100% through its full bake window, not merely passed one health check. That's what makes the fallback promised above actually instant: if anything looks wrong even after a full promotion, blue's launch template and target group still exist, one update-auto-scaling-group call and one more set_weight away from serving 100% again — the exact escape hatch blue-green promises, layered underneath a day-to-day mechanism that's really a canary. AWS's own managed version of this pattern — CodeDeploy's blue-green deployment type for ECS and EC2 — automates a lot of what this script does by hand; see the AWS Developer Tools page. Building it once yourself here is what makes it obvious exactly what a managed tool like that is doing for you later.

Proving it: a build that passes its health check and still gets caught

☺ Like you're 10: The new register turns on fine and says "ready" — the bug is only in how it rings up a real sale, and only real customers find that out.

The most convincing bad build for this test isn't one that crashes outright — a crashed process fails its target-group health check and never receives a single request, canary or not. The build worth proving against is the one that passes /healthz because the process itself is alive and listening, while a real request path — a downstream call that got misconfigured in the same change — starts returning 5xx. Ship that build as a18e4f0 and watch the rollout:

git push origin main   # deploy-canary job starts once a reviewer approves the prod environment gate

# ...a few minutes in:
# traffic split -> blue 90% / canary 10%
# baking 180s at canary=10%...
# canary alarm tripped -- rolling back to 100% blue

aws elbv2 describe-rules --listener-arn "$(terraform -chdir=infra/envs/prod output -raw ...)" \
  --query 'Rules[?Priority==`100`].Actions[0].ForwardConfig.TargetGroups'
# blue back at Weight 100, canary at Weight 0 -- confirmed independently of the script's own log line

aws autoscaling describe-auto-scaling-groups --auto-scaling-group-names checkout-prod-canary \
  --query 'AutoScalingGroups[0].DesiredCapacity'
# 0 -- the bad instance is gone, not just untrafficked

The whole point is in that transcript: checkout-svc:a18e4f0 never received more than 10% of prod's real traffic, for no longer than one bake window, and nobody pressed a rollback button — the alarm crossing its threshold is what triggered rollback(). Compare that against what today's old deploy process (a straight cutover of the single prod target group) would have done with the exact same bad build: 100% of prod traffic, immediately, with a human's own attention as the only detection mechanism. That gap — bounded, automatic exposure versus unbounded, human-detected exposure — is the entire value this page adds.

⚠ Size the alarm against the bake time, or "automatic rollback" is theater

This page's alarm needs evaluation_periods (2) × period (60s) = 120 seconds of sustained breach to trip — comfortably inside the 180-second bake window above. If you widen the alarm to, say, 5 evaluation periods at 60 seconds each (300 seconds) without also widening the bake time, the weight step can complete and advance to the next step before the alarm has accumulated enough breaching datapoints to ever fire once. The deployment group still says "automatic rollback enabled" — it just can no longer mathematically trigger it in time, and every canary you run after that silently behaves like an all-at-once cutover with extra steps. Always check the arithmetic on both sides of that inequality, not just that the alarm exists.

What "done" looks like for Part 3, and where Part 4 picks up

☺ Like you're 10: A second lane that turns itself on, watches itself, and turns itself back off if it has to — proven, not assumed.

At the end of this part: prod has a second target group and a canary ASG sitting idle behind the same ALB Part 2 built; a weighted listener rule and a canary-scoped CloudWatch alarm both exist as reviewed, applied Terraform; a deploy-canary job in Part 1's pipeline shifts real traffic in steps and can roll itself back with no human pressing a button; and you have watched, with your own commands, a build that passed its health check still get caught and confined to 10% of traffic before it was reversed. Nothing here gets thrown away:

PartWhat it does with today's rollout mechanism
4 — ObservabilityReplaces this page's one narrow 5xx alarm with real dashboards across all four golden signals, on both target groups at once
5 — Incident ResponseRuns a real incident against prod and writes the postmortem against whichever fleet — blue, or a canary that finished promoting — was actually serving traffic
6 — Security HardeningTightens the canary launch template's security group and IAM role the same way it tightens blue's
🎬 At the Ship-It Guild
🐢

Timmy the Turtle: Ten percent, three minutes, then the alarm decides — not me refreshing a dashboard every ten seconds.

🐘

Ellie the Elephant: And I'm keeping the record — which SHA was on blue, which was on canary, and exactly which weight step we were on the moment the alarm tripped, if it does.

🦫

Benny the Beaver: Feels slow for one API call updating a listener rule.

🐢

Timmy: The call's instant. The three minutes is us waiting to see if it was a mistake.

👺

Gizmo the Gremlin: Or — hot take — skip straight to 100%. It passed its health check, right? 😈

🐢

Timmy: A health check means the process is alive, Gizmo. It says nothing about whether a real request through checkout-svc actually works. That's the whole reason the alarm watches real traffic, not a heartbeat.

🦫

Benny: ...fine. Ten percent it is.

✓ Checkpoint

1. Why does the canary-scoped CloudWatch alarm use the TargetGroup dimension instead of just watching the ALB's overall 5xx count? 2. Walk through what happens, step by step, when the alarm trips while the rule is holding at canary=50%. 3. Why does the rollout script drive the live weight shifts through aws elbv2 modify-rule instead of running terraform apply at each step? 4. Why does the bad build in this page's "prove it" section still pass its target-group health check while breaking real traffic, and what does that prove about relying on health checks alone?

Check your answers
  1. Because the alarm has to detect the canary's own error rate specifically. Blending it into the ALB-wide 5xx count means a canary erroring on 100% of its own small slice of traffic gets diluted by 90%+ healthy blue traffic underneath it and may never cross a meaningful threshold at all. The TargetGroup dimension isolates exactly the fleet under test.
  2. set_weight 0 runs, sending 100% of traffic back to blue; the canary ASG's desired capacity drops to 0, so the bad instance stops running (not just stops receiving traffic); and the script exits non-zero, which fails the deploy-canary job — a red run is the intended signal, not a bug. Blue, which never lost more than the bounded 50% share the canary held up to that point, keeps serving every request throughout.
  3. terraform apply is a full plan-against-locked-state cycle built for reviewed infrastructure changes, not a loop that needs to move every 60–180 seconds — running it that often holds the shared state lock repeatedly, is far slower than the bake window needs, and risks colliding with an unrelated infrastructure change some other CI run applies to the same prod state at the same moment. The AWS CLI talks directly to the already-existing listener rule's live weights, which is a runtime traffic operation, not an infrastructure change.
  4. The build's /healthz endpoint keeps answering 200 because the process itself is up and accepting connections — the defect lives in a specific request path's logic (a misconfigured downstream call), not in the process's own liveness. A target-group health check only proves an instance is alive; it says nothing about whether real requests running real logic succeed. That's exactly why a request-based, error-rate alarm — not a passing health check alone — is what has to gate the rollout for this whole class of bug to be caught at all.

Part 3 gave checkout-svc a real deployment strategy — a rollout that promotes on its own and rolls itself back on its own, gated by an alarm that watches only the fleet actually under test. Continue to Capstone Part 4 — Observability, where this page's one narrow alarm becomes real dashboards on real golden signals. Or step back to Ship It — Start Here for how this capstone's six parts fit together, revisit deployment strategies and infrastructure as code for the concepts behind what you just built, and drill this exact failure mode under timed conditions in Drill — Roll Back a Bad Deploy.