jongkwan.dev
Development · Essay №041

Blue/Green Deployment (3): Cloud and CI/CD Integration

Blue/Green on AWS, GCP, and Azure, plus automated pipelines with GitHub Actions and GitLab CI

Jongkwan Lee2026년 2월 2일13 min read
Contents

Blue/Green on every cloud comes down to shifting load balancer weights, and in CI/CD the design of the verify -> approve -> switch -> monitor flow matters more than the automation itself.

Key points

  • On every cloud, Blue/Green reduces to "shift traffic weights at the load balancer."
  • A CI/CD pipeline is defined less by deployment automation than by the verify -> approve -> switch -> monitor flow.
  • GitHub Actions and GitLab CI can both do the job; the deciding factor is which one your operations are already standardized on.

Switching mechanisms by cloud

All three clouds work on the same principle, cutting traffic over at the load balancer, but the point where the cut happens differs. The table below summarizes it, and each section that follows shows the implementation.

CloudPrimary switching mechanismManaged option
AWSALB Target Group weight adjustmentCodeDeploy (ECS/EC2)
GCPCloud Run tag traffic splitting / URL MapCloud Run revisions
AzureApp Service Deployment Slot swapTraffic Manager weight

Blue/Green deployment on AWS

On AWS, the combination is ALB (Application Load Balancer) Target Group weight control plus CodeDeploy. Pin the infrastructure with IaC (Infrastructure as Code) and script the switch command so it is repeatable.

ALB + Target Group architecture

Building the infrastructure with Terraform

hcl
# alb.tf
resource "aws_lb" "main" {
  name               = "my-app-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = var.public_subnets
 
  tags = {
    Name = "my-app-alb"
  }
}
 
# Blue/Green Target Groups - identical settings, so define both at once with for_each
resource "aws_lb_target_group" "app" {
  for_each = toset(["blue", "green"])
 
  name        = "my-app-${each.key}"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = var.vpc_id
  target_type = "ip"
 
  health_check {
    enabled             = true
    healthy_threshold   = 2
    interval            = 30
    matcher             = "200"
    path                = "/health"
    port                = "traffic-port"
    protocol            = "HTTP"
    timeout             = 5
    unhealthy_threshold = 3
  }
 
  tags = {
    Name  = "my-app-${each.key}"
    Color = each.key
  }
}
 
# Listener with weighted routing (initial: Blue 100, Green 0)
resource "aws_lb_listener" "main" {
  load_balancer_arn = aws_lb.main.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = var.certificate_arn
 
  default_action {
    type = "forward"
 
    forward {
      target_group {
        arn    = aws_lb_target_group.app["blue"].arn
        weight = 100
      }
      target_group {
        arn    = aws_lb_target_group.app["green"].arn
        weight = 0
      }
    }
  }
}

The two Target Groups are identical down to the health check, so for_each collapses the duplication. Changing only the weights of app["blue"] and app["green"] in the listener shifts traffic.

Switching traffic with the AWS CLI

bash
#!/bin/bash
# aws-switch-traffic.sh
 
ALB_LISTENER_ARN="arn:aws:elasticloadbalancing:..."
BLUE_TG_ARN="arn:aws:elasticloadbalancing:...blue"
GREEN_TG_ARN="arn:aws:elasticloadbalancing:...green"
TARGET_COLOR=${1:-green}
 
if [ "$TARGET_COLOR" == "green" ]; then
  BLUE_WEIGHT=0
  GREEN_WEIGHT=100
else
  BLUE_WEIGHT=100
  GREEN_WEIGHT=0
fi
 
echo "Switching traffic: Blue=$BLUE_WEIGHT%, Green=$GREEN_WEIGHT%"
 
aws elbv2 modify-listener --listener-arn $ALB_LISTENER_ARN \
  --default-actions '[
    {
      "Type": "forward",
      "ForwardConfig": {
        "TargetGroups": [
          {"TargetGroupArn": "'$BLUE_TG_ARN'", "Weight": '$BLUE_WEIGHT'},
          {"TargetGroupArn": "'$GREEN_TG_ARN'", "Weight": '$GREEN_WEIGHT'}
        ]
      }
    }
  ]'
 
echo "Traffic switch completed!"

The first argument $1 sets the direction and fixes the Blue/Green weights at 0 and 100. Passing both Target Group Weight values through ForwardConfig.TargetGroups on modify-listener makes the ALB change the traffic split.

AWS CodeDeploy (ECS Blue/Green)

yaml
# appspec.yml (ECS)
version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: "arn:aws:ecs:region:account:task-definition/my-app:2"
        LoadBalancerInfo:
          ContainerName: "my-app"
          ContainerPort: 8080
        PlatformVersion: "LATEST"
Hooks:
  - BeforeInstall: "LambdaFunctionToValidateBeforeInstall"
  - AfterInstall: "LambdaFunctionToValidateAfterInstall"
  - AfterAllowTestTraffic: "LambdaFunctionToValidateAfterTestTraffic"
  - BeforeAllowTraffic: "LambdaFunctionToValidateBeforeTraffic"
  - AfterAllowTraffic: "LambdaFunctionToValidateAfterTraffic"

The Lambda behind each hook runs a stage-specific check, and returning a failure makes CodeDeploy abort the deployment. Attaching CloudWatch alarms to the CodeDeploy deployment group adds an automatic rollback to Blue when error rate or latency crosses a threshold after the switch. In production that is a safer option than the manual aws elbv2 modify-listener shown above.

Blue/Green deployment on GCP

GCP covers everything from serverless to Kubernetes with one strategy, using Cloud Run tag traffic splitting and the GKE (Google Kubernetes Engine) + Istio combination. Pick "simple traffic splitting" or "fine-grained mesh routing" depending on the service.

Cloud Load Balancing architecture

Cloud Run (serverless Blue/Green)

bash
# Traffic splitting on Cloud Run
# Assumes the currently serving revision was tagged --tag blue at the previous deploy.
 
# Deploy the new version (no traffic)
gcloud run deploy my-app \
  --image gcr.io/project/my-app:v1.1.0 \
  --region us-central1 \
  --no-traffic \
  --tag green
 
# Switch traffic (100% Green)
gcloud run services update-traffic my-app \
  --region us-central1 \
  --to-tags green=100
 
# Roll back (100% Blue)
# --to-latest points at the green revision just deployed, so do not use it to roll back.
gcloud run services update-traffic my-app \
  --region us-central1 \
  --to-tags blue=100

GKE Ingress + ManagedCertificate

The Ingress sends traffic to the my-app-active service, and the switch happens when that service's selector moves between Blue and Green.

yaml
# gcp-traffic-split.yaml
apiVersion: networking.gke.io/v1
kind: ManagedCertificate
metadata:
  name: my-app-cert
spec:
  domains:
    - my-app.example.com
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app
  annotations:
    kubernetes.io/ingress.class: "gce"
    networking.gke.io/managed-certificates: "my-app-cert"
spec:
  rules:
  - host: my-app.example.com
    http:
      paths:
      - path: /*
        pathType: ImplementationSpecific
        backend:
          service:
            name: my-app-active
            port:
              number: 80

Blue/Green deployment on Azure

Azure makes switch stability easy to reach through App Service Deployment Slots. When you need traffic-level switching, combine them with Traffic Manager or Application Gateway for a gradual rollout.

Application Gateway + Deployment Slots

App Service Deployment Slots

bash
# Deploy to the staging slot
az webapp deployment source config-zip \
  --resource-group my-rg \
  --name my-app \
  --slot staging \
  --src app.zip
 
# Swap slots (Blue/Green switch)
az webapp deployment slot swap \
  --resource-group my-rg \
  --name my-app \
  --slot staging \
  --target-slot production
 
# Roll back (swap again)
az webapp deployment slot swap \
  --resource-group my-rg \
  --name my-app \
  --slot staging \
  --target-slot production

Azure Traffic Manager

json
// traffic-manager.json (ARM Template)
{
  "type": "Microsoft.Network/trafficManagerProfiles",
  "apiVersion": "2018-08-01",
  "name": "my-app-tm",
  "location": "global",
  "properties": {
    "trafficRoutingMethod": "Weighted",
    "endpoints": [
      {
        "name": "blue-endpoint",
        "type": "Microsoft.Network/trafficManagerProfiles/externalEndpoints",
        "properties": {
          "target": "my-app-blue.azurewebsites.net",
          "endpointStatus": "Enabled",
          "weight": 100
        }
      },
      {
        "name": "green-endpoint",
        "type": "Microsoft.Network/trafficManagerProfiles/externalEndpoints",
        "properties": {
          "target": "my-app-green.azurewebsites.net",
          "endpointStatus": "Enabled",
          "weight": 0
        }
      }
    ]
  }
}

Each endpoint's weight value (0 or 100) is its traffic share, and endpointStatus turns the endpoint on or off. Start with Blue at 100 and Green at 0, then swap the two to switch.

GitHub Actions automation

Splitting the pipeline into health check, approval, and post-switch monitoring stages is what makes the deployment safe. A successful build alone does not trigger the switch, and a failure at any stage stops the next job from running.

Fully automated pipeline

Five jobs chain as build → deploy-green → test-green → promote, and rollback is separated out behind a workflow_dispatch manual trigger.

The Configure AWS credentials step is identical in every job, so below it appears in full only in the first job (build) and is reduced to a comment elsewhere.

yaml
# .github/workflows/blue-green-deploy.yml
name: Blue/Green Deployment
 
on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      action:
        description: 'Action to perform'
        required: true
        default: 'deploy'
        type: choice
        options:
          - deploy
          - promote
          - rollback
 
env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: my-app
  ECS_CLUSTER: my-cluster
  ECS_SERVICE: my-app-service
 
jobs:
  build:
    runs-on: ubuntu-latest
    if: github.event.inputs.action != 'rollback'
    outputs:
      image: ${{ steps.build-image.outputs.image }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}
 
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
 
      - name: Build, tag, and push image
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
 
  deploy-green:
    needs: build
    runs-on: ubuntu-latest
    if: github.event.inputs.action != 'rollback'
 
    steps:
      - uses: actions/checkout@v4
      # - name: Configure AWS credentials  (same as the build job, omitted)
 
      - name: Deploy to Green environment
        run: |
          # Update task definition with new image
          NEW_TASK_DEF=$(aws ecs describe-task-definition \
            --task-definition my-app-green \
            --query 'taskDefinition' | \
            jq --arg IMAGE "${{ needs.build.outputs.image }}" \
            '.containerDefinitions[0].image = $IMAGE | del(.taskDefinitionArn, .revision, .status, .requiresAttributes, .compatibilities, .registeredAt, .registeredBy)')
 
          # Register new task definition
          NEW_TASK_ARN=$(aws ecs register-task-definition \
            --cli-input-json "$NEW_TASK_DEF" \
            --query 'taskDefinition.taskDefinitionArn' \
            --output text)
 
          # Update Green service
          aws ecs update-service \
            --cluster $ECS_CLUSTER \
            --service my-app-green \
            --task-definition $NEW_TASK_ARN
 
      - name: Wait for Green deployment
        run: |
          aws ecs wait services-stable \
            --cluster $ECS_CLUSTER \
            --services my-app-green
 
  test-green:
    needs: deploy-green
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Run smoke tests on Green
        run: |
          GREEN_URL="${{ secrets.GREEN_INTERNAL_URL }}"
 
          # Health check
          for i in {1..10}; do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" $GREEN_URL/health)
            if [ "$STATUS" == "200" ]; then
              echo "Health check passed"
              break
            fi
            echo "Attempt $i: Status $STATUS, retrying..."
            sleep 5
          done
 
          # API tests
          ./scripts/smoke-tests.sh $GREEN_URL
 
  promote:
    needs: test-green
    runs-on: ubuntu-latest
    if: github.event.inputs.action != 'rollback'
    environment: production
 
    steps:
      # - name: Configure AWS credentials  (same as the build job, omitted)
 
      - name: Switch traffic to Green
        run: |
          aws elbv2 modify-listener --listener-arn ${{ secrets.ALB_LISTENER_ARN }} \
            --default-actions '[
              {
                "Type": "forward",
                "ForwardConfig": {
                  "TargetGroups": [
                    {"TargetGroupArn": "${{ secrets.BLUE_TG_ARN }}", "Weight": 0},
                    {"TargetGroupArn": "${{ secrets.GREEN_TG_ARN }}", "Weight": 100}
                  ]
                }
              }
            ]'
 
      - name: Notify Slack
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deployed ${{ github.sha }} to production",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Blue/Green Deployment Complete*\nCommit: `${{ github.sha }}`\nStatus: :white_check_mark: Success"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
  rollback:
    runs-on: ubuntu-latest
    if: github.event.inputs.action == 'rollback'
    environment: production
    # Same structure as the promote job. Only two things differ:
    #   1) Switch traffic sets the weights the other way: Blue=100, Green=0
    #   2) The Slack message becomes a rollback notification
    steps:
      # - Configure AWS credentials  (same as the build job)
      # - Rollback to Blue: modify-listener with Blue=100 / Green=0
      # - Notify Slack (Rollback)

Kubernetes + Argo Rollouts pipeline

yaml
# .github/workflows/k8s-blue-green.yml
name: K8s Blue/Green Deploy
 
on:
  push:
    branches: [main]
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Build and push image
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
 
      - name: Setup kubectl
        uses: azure/setup-kubectl@v3
 
      - name: Configure kubeconfig
        run: |
          echo "${{ secrets.KUBECONFIG }}" | base64 -d > kubeconfig
          export KUBECONFIG=kubeconfig
 
      - name: Update Rollout image
        run: |
          kubectl argo rollouts set image my-app \
            app=ghcr.io/${{ github.repository }}:${{ github.sha }}
 
      - name: Wait for rollout
        run: |
          kubectl argo rollouts status my-app --timeout=10m
 
      - name: Auto-promote (if tests pass)
        run: |
          # Wait for analysis to complete
          sleep 60
 
          # Check rollout status
          STATUS=$(kubectl argo rollouts status my-app -o json | jq -r '.status')
          if [ "$STATUS" == "Paused" ]; then
            kubectl argo rollouts promote my-app
          fi

GitLab CI/CD integration

GitLab CI builds the same flow out of build → deploy-green → test → promote → cleanup stages. Marking promote and rollback as when: manual means traffic only shifts after a human approves.

GitLab CI pipeline

yaml
# .gitlab-ci.yml
stages:
  - build
  - deploy-green
  - test
  - promote
  - cleanup
 
variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  KUBE_CONTEXT: production
 
build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE
 
deploy-green:
  stage: deploy-green
  image: bitnami/kubectl:latest
  script:
    - kubectl config use-context $KUBE_CONTEXT
    - |
      kubectl set image deployment/my-app-green \
        app=$DOCKER_IMAGE
    - kubectl rollout status deployment/my-app-green --timeout=300s
  environment:
    name: green
    url: https://green.my-app.example.com
 
test-green:
  stage: test
  image: curlimages/curl:latest
  script:
    - |
      for i in $(seq 1 10); do
        STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://green.my-app.example.com/health)
        if [ "$STATUS" = "200" ]; then
          echo "Health check passed"
          exit 0
        fi
        echo "Attempt $i failed with status $STATUS"
        sleep 5
      done
      exit 1
  needs:
    - deploy-green
 
promote:
  stage: promote
  image: bitnami/kubectl:latest
  script:
    - kubectl config use-context $KUBE_CONTEXT
    - |
      kubectl patch service my-app -p '{"spec":{"selector":{"color":"green"}}}'
    - echo "Traffic switched to Green"
  environment:
    name: production
    url: https://my-app.example.com
  when: manual
  needs:
    - test-green
 
rollback:
  stage: promote
  image: bitnami/kubectl:latest
  # Same as promote, only the selector goes back from green to blue.
  script:
    - kubectl config use-context $KUBE_CONTEXT
    - kubectl patch service my-app -p '{"spec":{"selector":{"color":"blue"}}}'
  when: manual
  needs:
    - test-green
 
cleanup-old-blue:
  stage: cleanup
  image: bitnami/kubectl:latest
  script:
    - kubectl config use-context $KUBE_CONTEXT
    # Update the old Blue to the new version so it becomes the standby for the next deploy.
    # Caution: from this point the old version is gone and there is nothing to roll back to,
    # so run this only after the post-switch monitoring window has closed.
    - |
      kubectl set image deployment/my-app-blue \
        app=$DOCKER_IMAGE
  when: manual
  needs:
    - promote

GitLab environment variables

bash
# GitLab CI/CD Variables
KUBECONFIG          # Kubernetes config (base64 encoded)
CI_REGISTRY         # Container Registry URL
AWS_ACCESS_KEY_ID   # AWS credentials (optional)
AWS_SECRET_ACCESS_KEY
SLACK_WEBHOOK_URL   # for notifications

Comparing deployment pipelines

The feature gaps between the three tools are small, as the table shows, so pick whichever integrates most easily with the VCS and permission model you already use. GitHub Actions and GitLab CI are simple to configure and ship environment approvals built in; Jenkins covers the same ground through plugins.

FeatureGitHub ActionsGitLab CIJenkins
Configuration complexityLowLowHigh
Parallel executionBuilt inBuilt inPlugin
Environment approvalEnvironmentsEnvironmentsPlugin
Secret managementSecretsVariablesCredentials
Self-hostedSupportedSupportedDefault
CostFree tier availableFree tier availableFree (infrastructure cost)

Summary

The switching mechanisms differ across the three clouds, but the principle is the same. AWS shifts traffic with ALB Target Group weights, GCP with Cloud Run tags and URL Maps, and Azure with a Deployment Slot swap. What matters in a CI/CD pipeline is not the deployment automation itself. It is separating verification, approval, switching, and monitoring into distinct stages and always keeping a rollback path open.

Design the structure that lets you reverse a bad switch quickly first. That holds whether the mechanism is CodeDeploy's automatic rollback on a CloudWatch alarm or manual approval on promote and rollback. This post took the Blue/Green concepts and zero-downtime switching procedure from the first two parts and moved them onto cloud platforms and CI/CD.