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
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 -> monitorflow. - 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.
| Cloud | Primary switching mechanism | Managed option |
|---|---|---|
| AWS | ALB Target Group weight adjustment | CodeDeploy (ECS/EC2) |
| GCP | Cloud Run tag traffic splitting / URL Map | Cloud Run revisions |
| Azure | App Service Deployment Slot swap | Traffic 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
# 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
#!/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)
# 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)
# 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=100GKE 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.
# 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: 80Blue/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
# 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 productionAzure Traffic Manager
// 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.
# .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
# .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
fiGitLab 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
# .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:
- promoteGitLab environment variables
# 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 notificationsComparing 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.
| Feature | GitHub Actions | GitLab CI | Jenkins |
|---|---|---|---|
| Configuration complexity | Low | Low | High |
| Parallel execution | Built in | Built in | Plugin |
| Environment approval | Environments | Environments | Plugin |
| Secret management | Secrets | Variables | Credentials |
| Self-hosted | Supported | Supported | Default |
| Cost | Free tier available | Free tier available | Free (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.