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.
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:
What you will build
- A VPC across two Availability Zones in
ap-south-1with public and private subnets - An ALB with a blue and a green target group
- An ECS Fargate service that uses the
CODE_DEPLOYdeployment 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
/healthwith HTTP 200, and returns its version on/version(read it from anAPP_VERSIONenvironment variable)
TipThe NAT gateway in this lab bills by the hour. Run
terraform destroywhen 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.
mkdir ecs-blue-green && cd ecs-blue-greenmkdir -p infracd infraCreate infra/main.tf:
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}terraform initterraform applyCommon MistakePutting Fargate tasks in private subnets with no NAT gateway or VPC endpoints. The tasks sit in
PROVISIONINGand then fail withCannotPullContainerError. 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:
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}TipHealth 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 MistakeForgetting
ignore_changes = [default_action]on the listener. After the first deployment CodeDeploy points the listener at green, and the nextterraform applyquietly 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:
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 ../appdocker push $REGISTRY/webapp:v1Add to infra/main.tf:
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:
aws ecs describe-task-definition --task-definition webapp \ --query taskDefinition \ | jq 'del(.taskDefinitionArn,.revision,.status,.requiresAttributes,.compatibilities,.registeredAt,.registeredBy)' \ > ../task-definition.jsonSecurityThe 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 MistakeUsing the
latestimage 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:
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 } } }}terraform applyTip
termination_wait_time_in_minutes = 5keeps 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 MistakeListing
DEPLOYMENT_STOP_ON_ALARMas 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 analarm_configurationblock.
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:
version: 0.0Resources: - TargetService: Type: AWS::ECS::Service Properties: TaskDefinition: <TASK_DEFINITION> LoadBalancerInfo: ContainerName: webapp ContainerPort: 8080 PlatformVersion: LATESTCreate .github/workflows/deploy.yml:
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: trueSecurityScope the role's trust policy to your exact repository and the
mainbranch, for example with asubcondition ofrepo:YOUR_ORG/YOUR_REPO:ref:refs/heads/main. A trust policy that accepts any repository lets anyone on GitHub assume your deploy role.
Common MistakeSetting
wait-for-service-stabilitywithout 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
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
ALB_DNS=$(terraform output -raw alb_dns_name)curl -s -o /dev/null -w "%{http_code}\n" http://$ALB_DNS/healthExpected 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:
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 1doneExpected 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
terraform destroyCommand 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.