Skip to main content

ECS Fargate Blue-Green Deployment with CodeDeploy

Build zero-downtime blue-green releases on ECS Fargate with Terraform, CodeDeploy and GitHub Actions, with automatic rollback on failed health checks.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a blue-green deployment pipeline for a containerised web app on AWS ECS Fargate. Terraform provisions the VPC, an Application Load Balancer with two target groups, and the ECS service. AWS CodeDeploy then shifts traffic from the old version (blue) to the new version (green) in steps, and rolls back automatically if the new tasks fail health checks or trip a CloudWatch alarm. A GitHub Actions workflow starts the whole thing on every push to main.

A blue-green deployment runs two copies of your service side by side. Blue serves live traffic while green is started and checked. Only when green is healthy does the load balancer move users over. A plain rolling update stops old tasks while new ones start, so a bad release or a slow start can show users 503 errors. With blue-green, a failed release never takes traffic, and blue stays available until you are sure.

This is a good fit for teams on ECS that want safe releases without the operational weight of Kubernetes. Think of a Swiggy-style ordering service at lunch peak: even a short error window hurts, so the release process must not create one.

flowchart TD push([Push to main]):::code --> ci[GitHub Actions
build and push]:::tool ci --> ecr[(Amazon ECR)]:::cloud ci --> cd[CodeDeploy
starts deployment]:::tool cd --> green[Green target group
new version]:::green alb[ALB listener]:::cloud --> blue[Blue target group
current version]:::blue alb --> green classDef code fill:#DBEAFE,color:#1E293B classDef tool fill:#475569,color:#FFFFFF classDef cloud fill:#F97316,color:#FFFFFF classDef blue fill:#326CE5,color:#FFFFFF classDef green fill:#10B981,color:#FFFFFF

The sequence of one release looks like this:

sequenceDiagram participant GH as GitHub Actions participant CD as CodeDeploy participant ECS as ECS Fargate participant ALB as Load balancer GH->>CD: Create deployment CD->>ECS: Start green tasks ECS-->>CD: Green healthy CD->>ALB: Shift 10% each minute CD->>ECS: Stop blue after wait

What you will build

  • A VPC across two Availability Zones in ap-south-1 with public and private subnets
  • An ALB with a blue and a green target group
  • An ECS Fargate service that uses the CODE_DEPLOY deployment controller
  • A CodeDeploy application and deployment group with linear traffic shifting, an alarm and automatic rollback
  • A GitHub Actions pipeline that deploys through OIDC, with no long-lived AWS keys

Prerequisites

  • An AWS account and the AWS CLI configured
  • Terraform 1.7 or newer
  • Docker and a GitHub repository for a small web app that listens on port 8080, answers /health with HTTP 200, and returns its version on /version (read it from an APP_VERSION environment variable)
Tip

The NAT gateway in this lab bills by the hour. Run terraform destroy when you finish, and check your own AWS bill to measure what the lab actually cost you.

Milestone 1: Provision the Network and Registry with Terraform

This milestone creates the VPC, subnets, NAT gateway, security groups and the ECR repository that everything else sits on.

Fargate tasks in private subnets cannot pull images from ECR or ship logs to CloudWatch without a route to the internet or to VPC endpoints. A single NAT gateway is the simplest option for a lab. A production setup would use one per AZ, or VPC endpoints, for resilience.

Bash
mkdir ecs-blue-green && cd ecs-blue-green
mkdir -p infra
cd infra

Create infra/main.tf:

HCL
terraform {
required_version = ">= 1.7.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# For a team setup, add an S3 backend with state locking here.
}
provider "aws" {
region = "ap-south-1"
}
locals {
azs = ["ap-south-1a", "ap-south-1b"]
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = { Name = "ecs-bg-vpc" }
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = { Name = "ecs-bg-public-${count.index}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index + 4)
availability_zone = local.azs[count.index]
tags = { Name = "ecs-bg-private-${count.index}" }
}
resource "aws_eip" "nat" {
domain = "vpc"
}
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.igw]
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
resource "aws_security_group" "alb" {
name = "ecs-bg-alb"
vpc_id = aws_vpc.main.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"]
}
}
resource "aws_security_group" "ecs_tasks" {
name = "ecs-bg-tasks"
vpc_id = aws_vpc.main.id
# Tasks accept traffic only from the load balancer
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"]
}
}
resource "aws_ecr_repository" "app" {
name = "webapp"
image_tag_mutability = "IMMUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/webapp"
retention_in_days = 14
}
Bash
terraform init
terraform apply
Common Mistake

Putting Fargate tasks in private subnets with no NAT gateway or VPC endpoints. The tasks sit in PROVISIONING and then fail with CannotPullContainerError. Fix it by adding the NAT route shown above.

Milestone 2: Create the ALB and Two Target Groups

This milestone adds the load balancer and the blue and green target groups that CodeDeploy will swap between.

CodeDeploy does not move tasks. It moves target groups. Both groups sit behind one listener, and CodeDeploy changes which group receives how much traffic. Fargate uses awsvpc networking, so the target type must be ip, not instance.

Add to infra/main.tf:

HCL
resource "aws_lb" "main" {
name = "ecs-bg-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
}
resource "aws_lb_target_group" "blue" {
name = "ecs-bg-blue"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/health"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 15
matcher = "200"
}
}
resource "aws_lb_target_group" "green" {
name = "ecs-bg-green"
port = 8080
protocol = "HTTP"
vpc_id = aws_vpc.main.id
target_type = "ip"
health_check {
path = "/health"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 15
matcher = "200"
}
}
# The listener starts on blue. CodeDeploy rewrites this action during releases.
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.blue.arn
}
lifecycle {
ignore_changes = [default_action]
}
}
output "alb_dns_name" {
value = aws_lb.main.dns_name
}
Tip

Health check timing sets how fast a bad release is caught. With an interval of 15 seconds and 3 failures, a broken green task is marked unhealthy in under a minute. The slower the check, the longer a release waits before it can fail.

Common Mistake

Forgetting ignore_changes = [default_action] on the listener. After the first deployment CodeDeploy points the listener at green, and the next terraform apply quietly points it back at blue.

Milestone 3: Run the ECS Service Under CodeDeploy Control

This milestone creates the ECS cluster, IAM roles, task definition and a service that hands release control to CodeDeploy.

Setting deployment_controller { type = "CODE_DEPLOY" } tells ECS to stop doing its own rolling updates. From then on, every release is a CodeDeploy deployment that creates a new task set behind the green target group. Terraform must therefore stop managing the task definition and load balancer fields, or it will fight CodeDeploy.

The service needs an image to start. Push a first image before applying:

Bash
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGISTRY=$ACCOUNT_ID.dkr.ecr.ap-south-1.amazonaws.com
aws ecr get-login-password --region ap-south-1 \
| docker login --username AWS --password-stdin $REGISTRY
docker build -t $REGISTRY/webapp:v1 ../app
docker push $REGISTRY/webapp:v1

Add to infra/main.tf:

HCL
variable "initial_image_tag" {
type = string
default = "v1"
}
resource "aws_ecs_cluster" "main" {
name = "production-cluster"
setting {
name = "containerInsights"
value = "enabled"
}
}
data "aws_iam_policy_document" "ecs_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs_execution" {
name = "ecs-bg-execution"
assume_role_policy = data.aws_iam_policy_document.ecs_assume.json
}
resource "aws_iam_role_policy_attachment" "ecs_execution" {
role = aws_iam_role.ecs_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_ecs_task_definition" "app" {
family = "webapp"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.ecs_execution.arn
container_definitions = jsonencode([{
name = "webapp"
image = "${aws_ecr_repository.app.repository_url}:${var.initial_image_tag}"
essential = true
portMappings = [{
containerPort = 8080
protocol = "tcp"
}]
environment = [{ name = "APP_VERSION", value = "v1" }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = "ap-south-1"
"awslogs-stream-prefix" = "ecs"
}
}
}])
}
resource "aws_ecs_service" "app" {
name = "webapp"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.blue.arn
container_name = "webapp"
container_port = 8080
}
deployment_controller {
type = "CODE_DEPLOY"
}
depends_on = [aws_lb_listener.http]
lifecycle {
# CodeDeploy owns these after the first deployment
ignore_changes = [task_definition, load_balancer, desired_count]
}
}

Export the running task definition into your app repository. The pipeline will use it as a template:

Bash
aws ecs describe-task-definition --task-definition webapp \
--query taskDefinition \
| jq 'del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy)' \
> ../task-definition.json
Security

The execution role only needs to pull images and write logs, so it uses the managed AmazonECSTaskExecutionRolePolicy. Do not attach admin policies to task roles to get past a permission error. Add the one missing action instead.

Common Mistake

Using the latest image tag. ECS and CodeDeploy compare task definitions, and a moving tag makes it impossible to know what is running or to roll back to a known version. Tag images with the commit SHA, as the pipeline below does.

Milestone 4: Configure the CodeDeploy Blue-Green Deployment Group

This milestone creates the CodeDeploy application and deployment group that control the traffic shift, the alarm and the rollback.

The deployment configuration decides how fast traffic moves. ECSLinear10PercentEvery1Minutes moves 10% of traffic every minute, so a full shift takes roughly ten minutes. That gives your alarm time to catch a problem while most users are still on blue. The table compares the built-in options.

Configuration Behaviour When to use it
CodeDeployDefault.ECSAllAtOnce Moves 100% of traffic at once Dev and test environments
CodeDeployDefault.ECSLinear10PercentEvery1Minutes 10% more each minute Default for this lab
CodeDeployDefault.ECSLinear10PercentEvery3Minutes 10% more every 3 minutes Slower, safer for busy services
CodeDeployDefault.ECSCanary10Percent5Minutes 10% first, rest after 5 minutes Quick check, then full move

Rollback has two triggers here. If green tasks never become healthy, the deployment fails before any traffic moves. If green becomes healthy but starts returning errors, a CloudWatch alarm stops the deployment mid-shift and traffic returns to blue.

Add to infra/main.tf:

HCL
data "aws_iam_policy_document" "codedeploy_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["codedeploy.amazonaws.com"]
}
}
}
resource "aws_iam_role" "codedeploy" {
name = "ecs-bg-codedeploy"
assume_role_policy = data.aws_iam_policy_document.codedeploy_assume.json
}
resource "aws_iam_role_policy_attachment" "codedeploy" {
role = aws_iam_role.codedeploy.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS"
}
resource "aws_cloudwatch_metric_alarm" "target_5xx" {
alarm_name = "webapp-target-5xx"
namespace = "AWS/ApplicationELB"
metric_name = "HTTPCode_Target_5XX_Count"
statistic = "Sum"
period = 60
evaluation_periods = 1
threshold = 5
comparison_operator = "GreaterThanThreshold"
treat_missing_data = "notBreaching"
dimensions = {
LoadBalancer = aws_lb.main.arn_suffix
}
}
resource "aws_codedeploy_app" "app" {
compute_platform = "ECS"
name = "webapp-deployment"
}
resource "aws_codedeploy_deployment_group" "app" {
app_name = aws_codedeploy_app.app.name
deployment_group_name = "webapp-production"
service_role_arn = aws_iam_role.codedeploy.arn
deployment_config_name = "CodeDeployDefault.ECSLinear10PercentEvery1Minutes"
alarm_configuration {
enabled = true
alarms = [aws_cloudwatch_metric_alarm.target_5xx.alarm_name]
}
auto_rollback_configuration {
enabled = true
events = ["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM"]
}
blue_green_deployment_config {
deployment_ready_option {
action_on_timeout = "CONTINUE_DEPLOYMENT"
}
terminate_blue_instances_on_deployment_success {
action = "TERMINATE"
termination_wait_time_in_minutes = 5
}
}
deployment_style {
deployment_option = "WITH_TRAFFIC_CONTROL"
deployment_type = "BLUE_GREEN"
}
ecs_service {
cluster_name = aws_ecs_cluster.main.name
service_name = aws_ecs_service.app.name
}
load_balancer_info {
target_group_pair_info {
prod_traffic_route {
listener_arns = [aws_lb_listener.http.arn]
}
target_group {
name = aws_lb_target_group.blue.name
}
target_group {
name = aws_lb_target_group.green.name
}
}
}
}
Bash
terraform apply
Tip

termination_wait_time_in_minutes = 5 keeps the old blue tasks running for five minutes after the shift completes. During that window you can stop the deployment from the console and traffic returns to blue immediately. Raise it for services where problems surface slowly.

Common Mistake

Listing DEPLOYMENT_STOP_ON_ALARM as a rollback event without configuring any alarm. The event can never fire, so a release that returns errors keeps shifting traffic. Always pair the event with an alarm_configuration block.

Milestone 5: Automate Releases with GitHub Actions

This milestone adds the CI workflow that builds an image, registers a new task definition and starts a CodeDeploy deployment on every push to main.

The workflow authenticates with OIDC, which lets GitHub request short-lived AWS credentials for one specific role. That removes stored access keys from your repository secrets entirely.

First create an IAM role named github-deploy-role whose trust policy allows token.actions.githubusercontent.com for your repository, and whose permissions allow ECR push, ecs:RegisterTaskDefinition, ecs:DescribeServices, iam:PassRole on the two ECS roles, and the codedeploy:* calls used to create and read deployments.

Create appspec.yml in the repository root:

YAML
version: 0.0
Resources:
- TargetService:
Type: AWS::ECS::Service
Properties:
TaskDefinition: <TASK_DEFINITION>
LoadBalancerInfo:
ContainerName: webapp
ContainerPort: 8080
PlatformVersion: LATEST

Create .github/workflows/deploy.yml:

YAML
name: Deploy to ECS with blue-green
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-deploy-role
aws-region: ap-south-1
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
id: build
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/webapp:$IMAGE_TAG .
docker push $ECR_REGISTRY/webapp:$IMAGE_TAG
echo "image=$ECR_REGISTRY/webapp:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Render task definition with new image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: webapp
image: ${{ steps.build.outputs.image }}
- name: Deploy through CodeDeploy
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: webapp
cluster: production-cluster
codedeploy-appspec: appspec.yml
codedeploy-application: webapp-deployment
codedeploy-deployment-group: webapp-production
wait-for-service-stability: true
Security

Scope the role's trust policy to your exact repository and the main branch, for example with a sub condition of repo:YOUR_ORG/YOUR_REPO:ref:refs/heads/main. A trust policy that accepts any repository lets anyone on GitHub assume your deploy role.

Common Mistake

Setting wait-for-service-stability without raising the job timeout on slow shifts. A ten-minute linear shift plus the wait window can exceed short CI timeouts, and the job fails even though the deployment itself succeeds. Check the CodeDeploy console before assuming a failure.

Validation & Testing

These checks prove the service is healthy, that traffic really shifts, and that both rollback paths work. Run them in order.

1. Confirm the service is running

Bash
aws ecs describe-services \
--cluster production-cluster \
--services webapp \
--query 'services[0].[runningCount,desiredCount]'

Expected output: the two numbers match, for example [2, 2].

2. Confirm the load balancer answers

Bash
ALB_DNS=$(terraform output -raw alb_dns_name)
curl -s -o /dev/null -w "%{http_code}\n" http://$ALB_DNS/health

Expected output: 200.

3. Run a release while watching traffic

Start this in one terminal, then push a commit that changes APP_VERSION in a second:

Bash
while true; do
echo "$(date +%T) $(curl -s -o /dev/null -w '%{http_code}' http://$ALB_DNS/health) $(curl -s http://$ALB_DNS/version)"
sleep 1
done

Expected output: every line shows 200. During the shift you see both the old and the new version in the same minute, and the share of the new version grows until only it remains. Watch the deployment in the console under CodeDeploy, then Deployments.

4. Test rollback when green is unhealthy

Deploy an image whose /health returns HTTP 500.

Expected output: green tasks never pass health checks, the deployment ends as Failed and then rolls back, and the loop from step 3 keeps printing 200 from blue throughout. No user traffic ever reached the bad version.

5. Test rollback when green returns errors

Deploy an image where /health passes but / returns HTTP 500, then send steady traffic to /.

Expected output: once 5xx errors pass the alarm threshold, CodeDeploy stops the deployment and moves traffic back to blue. The deployment shows Stopped and the alarm shows In alarm.

6. Clean up

Bash
terraform destroy

Command reference

Command What it checks
aws ecs describe-services Running and desired task counts
aws deploy list-deployments Recent CodeDeploy deployments
aws deploy get-deployment --deployment-id ID Status and rollback reason
aws deploy stop-deployment --deployment-id ID --auto-rollback-enabled Manual stop with rollback

Portfolio deliverable

Keep a short write-up with the Terraform repository, a screenshot of the CodeDeploy traffic shift, and the output of your 1-second loop during a release. Record your own measured shift time and rollback time instead of quoting a target, since they depend on the health check and shift settings you chose.

Found a mistake, an outdated step or a broken link?