How to set up blue-green deployments with CircleCI
Content Marketing Manager
How to set up blue-green deployments with CircleCI
A blue-green deployment runs two identical production environments and switches traffic between them when shipping a new version.
One environment is “active” and serves all traffic. The other sits idle. To deploy, the team pushes the new version to the idle environment, validates it, then switches traffic over. If something breaks, traffic flips back in seconds. No pods restart, no rolling update in reverse.
In a CI/CD workflow (where code changes are automatically built, tested, and deployed), blue-green separates the deploy step from the release step cleanly. CircleCI automates each stage: build the image, deploy to the idle environment, run validation tests, hold at an approval gate, and switch traffic only after a human confirms.
This tutorial guides you through setting up a blue-green deployment on Kubernetes, automated inside a CircleCI pipeline. It covers the dual-environment manifests, a preview Service for pre-switch validation, an approval-gated workflow, deploy tracking, instant rollback, and the database migration pattern that prevents the most common blue-green failure.
Prerequisites
Before starting, the team needs:
- A CircleCI account (free tier works).
- A Kubernetes cluster with
kubectlconfigured to reach it (this tutorial uses GKE, but EKS, AKS, or a local cluster like minikube or kind all work). - Docker installed locally for building the sample image.
- A Docker Hub account for the container registry.
- The sample repository cloned from GitHub: CIRCLECI-GWP/kubernetes-blue-green-deployment
How blue-green deployments work in Kubernetes
Two Kubernetes Deployments exist side by side, one labeled version: blue, one labeled version: green. A Service routes traffic to whichever Deployment matches its selector. Only one is active at a time. The other runs idle, waiting for the next release.
Switching from blue to green means patching the Service selector from version: blue to version: green. This is a metadata change, not a pod operation. Traffic shifts immediately. No pods are created, terminated, or restarted.
Before the switch, the team can validate the idle environment. A separate “preview” Service points permanently at the green Deployment, giving the pipeline an endpoint to run smoke tests, integration tests, or load tests against the new version. Because the green pods run on production infrastructure with access to the production database, tests here catch issues that staging environments miss.
Both deployments share the same database and external services. This is the source of most blue-green failures: a schema change that works with the green code but breaks the blue code makes rollback impossible. Step 5 covers the migration pattern that prevents this.
The trade-off: blue-green doubles compute during the deployment window because both environments run full replica sets simultaneously. For teams where that cost matters, rolling deployments update pods incrementally without a duplicate environment.
Step 1 — Build and containerize the sample application
The sample application is a Node.js HTTP server with no dependencies. It serves three endpoints:
GET /returns a styled HTML status page showing the running version, deployment slot (blue or green), pod hostname, and a timestamp.GET /apireturns the same information as JSON.GET /healthreturns{"status":"ok"}for readiness and liveness probes.
The version comes from the APP_VERSION environment variable, and the slot comes from DEPLOY_SLOT. Both are set by the Kubernetes manifest at deploy time. Hitting GET /api on the blue and green environments returns different slot values, making the switch visible.
The full source is in the sample repository. The Dockerfile uses node:20-alpine and runs as a non-root user:
FROM node:20-alpine
WORKDIR /app
COPY package.json .
COPY index.js .
EXPOSE 3000
USER node
CMD ["node", "index.js"]
Build and test locally before pushing:
docker build -t blue-green-tutorial-app:local ./app
docker run -p 3000:3000 -e DEPLOY_SLOT=blue blue-green-tutorial-app:local
# In another terminal:
curl localhost:3000/api
# {"version":"1.0.0","slot":"blue","hostname":"...","timestamp":"..."}
curl localhost:3000/health
# {"status":"ok"}
Step 2 — Write the Kubernetes manifests
Blue-green on Kubernetes needs four manifests: two Deployments (one per environment) and two Services (production and preview). All four use envsubst placeholders that the CircleCI pipeline fills in at deploy time. envsubst replaces ${VARIABLE} references in a file with their environment variable values, so the pipeline can inject the image tag and Docker Hub username before applying the manifests.
Blue deployment
The blue Deployment in k8s/deployment-blue.yml runs the current production version. The version: blue label distinguishes its pods from green pods.
apiVersion: apps/v1
kind: Deployment
metadata:
name: blue-green-tutorial-app-blue
namespace: default
annotations:
circleci.com/project-id: ${CIRCLE_PROJECT_ID}
circleci.com/operation-timeout: 10m
labels:
app: blue-green-tutorial-app
version: blue
circleci.com/component-name: blue-green-tutorial-app
circleci.com/version: ${IMAGE_TAG}
spec:
replicas: 3
selector:
matchLabels:
app: blue-green-tutorial-app
version: blue
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: blue-green-tutorial-app
version: blue
circleci.com/component-name: blue-green-tutorial-app
circleci.com/version: ${IMAGE_TAG}
spec:
containers:
- name: blue-green-tutorial-app
image: ${DOCKERHUB_USERNAME}/blue-green-tutorial-app:${IMAGE_TAG}
ports:
- containerPort: 3000
env:
- name: APP_VERSION
value: "${IMAGE_TAG}"
- name: DEPLOY_SLOT
value: "blue"
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 10
The key settings to note:
version: bluelabel. Appears inselector.matchLabels, the pod template, and the production Service’s selector. This is what isolates blue pods as a distinct set and lets the Service target them specifically.maxUnavailable: 0andmaxSurge: 1. When the pipeline applies a new image to the green Deployment, no pods go offline until a replacement is ready, and only one extra pod runs at a time.circleci.com/*labels and annotations. CircleCI’s deploy tracking reads these from running pods. They must not go inspec.selector.matchLabels, which Kubernetes treats as immutable. Review the component configuration docs for the full reference.
The green Deployment in k8s/deployment-green.yml is identical except all blue references become green: the metadata name, version label, and DEPLOY_SLOT value.
Production Service
The production Service in k8s/service.yml routes traffic to whichever environment is active. Initially, it selects version: blue:
apiVersion: v1
kind: Service
metadata:
name: blue-green-tutorial-app
namespace: default
spec:
selector:
app: blue-green-tutorial-app
version: blue
ports:
- port: 80
targetPort: 3000
type: LoadBalancer
Switching traffic means patching this Service’s selector from version: blue to version: green. That single change redirects all production traffic to the green pods.
Preview Service
The preview Service in k8s/service-preview.yml always points to the green environment. It gives the pipeline an endpoint to validate the new version before the production switch:
apiVersion: v1
kind: Service
metadata:
name: blue-green-tutorial-app-preview
namespace: default
spec:
selector:
app: blue-green-tutorial-app
version: green
ports:
- port: 80
targetPort: 3000
type: LoadBalancer
Create the cluster
This tutorial uses GKE for the cluster, but any Kubernetes cluster works. The manifests and pipeline are cloud-agnostic: EKS, AKS, DigitalOcean, or a local cluster like minikube or kind all run the same workflow.
For GKE, a single gcloud command creates the cluster:
gcloud container clusters create blue-green-tutorial \
--zone=us-central1-a \
--num-nodes=3 \
--machine-type=e2-medium
Then configure kubectl to talk to the new cluster:
gcloud container clusters get-credentials blue-green-tutorial --zone=us-central1-a
get-credentials writes the cluster’s endpoint and credentials into ~/.kube/config and sets it as the active context. After this, every kubectl command targets the new cluster. No separate connection step needed.
Google Cloud’s GKE quickstart covers the full setup, including project setup, authentication, and billing. For other providers, follow their equivalent docs. The pattern is the same: provision a cluster, then point kubectl at it.
Verify the connection before moving on:
kubectl get nodes
Once nodes return, the rest of this tutorial is cloud-agnostic. Every command from here works the same regardless of where the cluster runs.
Initial setup
Before the first pipeline run, apply both Deployments and both Services manually:
export IMAGE_TAG=initial
export DOCKERHUB_USERNAME=<your-dockerhub-username>
export CIRCLE_PROJECT_ID=<your-circleci-project-id>
envsubst < k8s/deployment-blue.yml | kubectl apply -f -
envsubst < k8s/deployment-green.yml | kubectl apply -f -
kubectl apply -f k8s/service.yml
kubectl apply -f k8s/service-preview.yml
Both environments start with the same image. From here, the pipeline handles all updates to the green environment.
Step 3 — Create the CircleCI pipeline with approval gates
Connect the repository
In CircleCI, click Projects in the left sidebar, find the repository, and click Set Up Project. Select the Fastest option to use the existing .circleci/config.yml. For a full walkthrough, see Getting started with CircleCI.
Create the context
The pipeline needs secrets that shouldn’t live in the repository. CircleCI contexts store environment variables available to jobs at runtime. Create one named blue-green-tutorial:
- In CircleCI, go to Organization Settings > Contexts.
- Click Create Context and name it
blue-green-tutorial. - Add three environment variables:
| Variable | Value |
|---|---|
DOCKERHUB_USERNAME |
Docker Hub username |
DOCKERHUB_PASSWORD |
Docker Hub password or access token |
KUBECONFIG_DATA |
Base64-encoded kubeconfig for the cluster |
To generate KUBECONFIG_DATA for GKE:
gcloud container clusters get-credentials <cluster-name> --zone <zone> --project <project-id>
cat ~/.kube/config | base64 | tr -d '[:space:]'
For EKS, AKS, or other providers, configure kubectl access per the provider’s documentation, then encode the resulting kubeconfig the same way.
Pipeline config
The pipeline has five jobs in a linear workflow. This is where blue-green diverges from rolling: instead of deploying and releasing in one step, the pipeline separates them with a validation gate.
version: 2.1
commands:
setup-kubectl:
description: Install kubectl and configure cluster access
steps:
- run:
name: Install kubectl
command: |
KUBECTL_VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt)
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
- run:
name: Configure kubeconfig
command: |
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" | tr -d '[:space:]' | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
jobs:
build-and-push:
docker:
- image: cimg/base:stable
steps:
- checkout
- setup_remote_docker:
docker_layer_caching: true
- run:
name: Build Docker image
command: |
docker build -t $DOCKERHUB_USERNAME/blue-green-tutorial-app:$CIRCLE_SHA1 ./app
- run:
name: Push to Docker Hub
command: |
echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
docker push $DOCKERHUB_USERNAME/blue-green-tutorial-app:$CIRCLE_SHA1
deploy-to-green:
docker:
- image: cimg/base:stable
steps:
- checkout
- setup-kubectl
- run:
name: Install envsubst
command: sudo apt-get update -qq && sudo apt-get install -y gettext-base
- run:
name: Deploy to green environment
command: |
export IMAGE_TAG=$CIRCLE_SHA1
envsubst < k8s/deployment-green.yml | kubectl apply -f -
kubectl apply -f k8s/service.yml
kubectl apply -f k8s/service-preview.yml
- run:
name: Wait for green rollout
command: kubectl rollout status deployment/blue-green-tutorial-app-green --timeout=5m
validate-green:
docker:
- image: cimg/base:stable
steps:
- setup-kubectl
- run:
name: Get preview service endpoint
command: |
for i in $(seq 1 30); do
PREVIEW_IP=$(kubectl get svc blue-green-tutorial-app-preview \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}')
if [ -n "$PREVIEW_IP" ]; then
echo "Preview IP: $PREVIEW_IP"
echo "export PREVIEW_IP=$PREVIEW_IP" >> "$BASH_ENV"
break
fi
sleep 10
done
if [ -z "$PREVIEW_IP" ]; then
echo "Preview service did not receive an external IP"
exit 1
fi
- run:
name: Smoke test - health check
command: |
curl -sf http://$PREVIEW_IP/health | grep -q '"status":"ok"'
- run:
name: Smoke test - version check
command: |
curl -sf http://$PREVIEW_IP/api | grep -q "$CIRCLE_SHA1"
- run:
name: Smoke test - slot check
command: |
curl -sf http://$PREVIEW_IP/api | grep -q '"slot":"green"'
switch-traffic:
docker:
- image: cimg/base:stable
steps:
- setup-kubectl
- run:
name: Plan deployment
command: |
circleci run release plan "${CIRCLE_JOB}" \
--environment-name="production" \
--component-name="blue-green-tutorial-app" \
--target-version="$CIRCLE_SHA1"
- run:
name: Switch production traffic to green
command: |
kubectl patch svc blue-green-tutorial-app \
-p '{"spec":{"selector":{"version":"green"}}}'
- run:
name: Update deployment status to running
command: circleci run release update "${CIRCLE_JOB}" --status=RUNNING
- run:
name: Log deploy marker
command: |
circleci run release log \
--component-name=blue-green-tutorial-app \
--environment-name=production \
--target-version=$CIRCLE_SHA1
- run:
name: Update deployment status to success
command: circleci run release update "${CIRCLE_JOB}" --status=SUCCESS
when: on_success
- run:
name: Update deployment status to failed
command: circleci run release update "${CIRCLE_JOB}" --status=FAILED
when: on_fail
workflows:
build-deploy:
jobs:
- build-and-push:
context: blue-green-tutorial
- deploy-to-green:
requires:
- build-and-push
context: blue-green-tutorial
filters:
branches:
only: main
- validate-green:
requires:
- deploy-to-green
context: blue-green-tutorial
- hold-for-approval:
type: approval
requires:
- validate-green
- switch-traffic:
requires:
- hold-for-approval
context: blue-green-tutorial
Walking through each job:
build-and-push builds the Docker image and tags it with the Git commit SHA ($CIRCLE_SHA1), so every image traces back to the exact commit that produced it.
deploy-to-green applies the green Deployment manifest with the new image tag and both Service manifests. Then it waits for the green rollout to complete. At this point, the new code is running in the green environment, but production traffic still goes to blue.
validate-green runs smoke tests against the preview Service. It checks that the health endpoint responds, that the API returns the expected version, and that the response comes from the green slot. If any test fails, the job fails and the approval gate never appears. Production stays on blue.
hold-for-approval is a CircleCI manual approval job. The pipeline pauses here. An engineer reviews the validation results, optionally runs additional manual checks against the preview endpoint, and clicks “Approve” in the CircleCI UI to proceed. This is the human checkpoint between deploying code and releasing it to users.
switch-traffic patches the production Service selector from version: blue to version: green. Traffic shifts immediately. This is the moment of release.
The job also writes deploy markers throughout. release plan registers the planned deploy before any work starts. After the switch, release update --status=RUNNING marks it active and release log records the event. A final release update closes the lifecycle with SUCCESS or FAILED. These markers feed CircleCI’s Deploys dashboard, where each release shows up as a timeline entry linked to the pipeline and commit.
The deploy chain runs only on main. Pull request builds stop after build-and-push without triggering a deploy.
Step 4 — Validate the green environment before switching
The validate-green job runs three checks against the preview Service endpoint:
- Health check:
curl -sf http://$PREVIEW_IP/healthreturns{"status":"ok"}. - Version check: The
/apiresponse contains the expected commit SHA. - Slot check: The
/apiresponse confirms"slot":"green".
These three are the floor. A real production setup would add a smoke test suite covering critical API endpoints, a lightweight load test against the preview endpoint, and any database connectivity or migration checks the application needs. The validation job can run anything that makes HTTP requests, since the preview Service gives it a stable target.
Whatever the test suite, it runs against production infrastructure. Failures from connection pool limits, real data edge cases, or production-only config values surface here, before traffic shifts.
If validation fails, the pipeline stops. The approval gate never appears. Production traffic stays on blue. The team can inspect the green environment, check logs with kubectl logs -l version=green, and fix the issue before the next pipeline run.
Once the pipeline runs and switches traffic to green, the application serves a status page confirming the deployment:
Step 5 — Handle database migrations safely
The expand-migrate-contract pattern lets schema changes ship through a blue-green pipeline without breaking either version of the app. It works in three phases:
Expand phase. Add new columns or tables alongside existing ones. Don’t remove anything. Deploy this schema change before the application change. The current blue code keeps working because nothing it depends on has been removed. The new green code works because the new structures exist.
Migrate phase. Backfill data from old structures into new ones. This can run as a background job or as a pipeline step after the schema expand is applied.
Contract phase. Once all traffic runs on the green version, and the team has confirmed the blue environment won’t be needed for rollback, remove the old columns. This is a separate deploy, run days or weeks later.
For example, renaming a column from user_name to username plays out like this:
| Phase | Schema state | Blue code | Green code |
|---|---|---|---|
| Before | user_name exists |
Reads user_name |
— |
| Expand | Both columns exist, trigger syncs | Reads user_name |
Reads username |
| Switch | Both columns exist | Still running, reads user_name |
Active, reads username |
| Contract | Only username exists |
Scaled down | Reads username |
The constraint throughout is N-1 compatibility. The new app version must work with both the old schema and the new schema. The old app version must also work with the new schema. If either compatibility breaks, rollback fails.
Run the schema migration as a separate job in the pipeline, before the deploy-to-green job, so the database is ready before the new code hits it.
Step 6 — Set up instant rollback
Rollback patches the production Service selector back to version: blue. Traffic returns to the previous version immediately. No pods restart, no new ReplicaSet, no rolling update in reverse. The blue Deployment is still running with the previous image, so switching back is a network-level change that takes effect in seconds.
The rollback pipeline in .circleci/rollback.yml automates this from the CircleCI Deploys dashboard:
version: 2.1
commands:
setup-kubectl:
description: Install kubectl and configure cluster access
steps:
- run:
name: Install kubectl
command: |
KUBECTL_VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt)
curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
chmod +x kubectl && sudo mv kubectl /usr/local/bin/kubectl
- run:
name: Configure kubeconfig
command: |
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" | tr -d '[:space:]' | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
jobs:
rollback:
docker:
- image: cimg/base:stable
environment:
COMPONENT_NAME: << pipeline.deploy.component_name >>
ENVIRONMENT_NAME: << pipeline.deploy.environment_name >>
TARGET_VERSION: << pipeline.deploy.target_version >>
steps:
- setup-kubectl
- run:
name: Plan rollback release
command: |
circleci run release plan "${CIRCLE_JOB}" \
--component-name=${COMPONENT_NAME} \
--environment-name=${ENVIRONMENT_NAME} \
--target-version=${TARGET_VERSION} \
--rollback
- run:
name: Switch production traffic back to blue
command: |
kubectl patch svc blue-green-tutorial-app \
-p '{"spec":{"selector":{"version":"blue"}}}'
- run:
name: Verify rollback
command: |
PROD_IP=$(kubectl get svc blue-green-tutorial-app \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl -sf http://$PROD_IP/api | grep -q '"slot":"blue"'
- run:
name: Update rollback status to SUCCESS
command: circleci run release update "${CIRCLE_JOB}" --status=SUCCESS
when: on_success
- run:
name: Update rollback status to FAILED
command: circleci run release update "${CIRCLE_JOB}" --status=FAILED
when: on_fail
cancel-rollback:
docker:
- image: cimg/base:stable
steps:
- run:
name: Update rollback status to CANCELED
command: circleci run release update "${CIRCLE_JOB}" --status=CANCELED
workflows:
rollback:
jobs:
- rollback:
context: blue-green-tutorial
- cancel-rollback:
context: blue-green-tutorial
requires:
- rollback:
- canceled
A few things to note:
pipeline.deploy.*parameters. Injected automatically when CircleCI triggers a rollback.release plan --rollback. Distinguishes this from a regular deploy in the dashboard.cancel-rollbackjob. Fires only on explicit cancellation, preventing stale “in progress” entries.
Connect the rollback pipeline
With the deploy markers from Step 3 committed to main, click Rollback > Create rollback pipeline from the project’s Overview page. The wizard handles four steps:
- GitHub App installation. The feature is GitHub-only; the wizard installs the App if needed.
- Pipeline definition. Select the project repository.
- Deploy markers. Since they’re already in the
switch-trafficjob, select I’ll make the config changes myself, then I’ve updated my config. - Rollback config. Select I already have a rollback config to use
.circleci/rollback.yml, then click Setup rollback pipeline.
Rollbacks now trigger from the project Overview page. The rollback pipeline documentation covers switching pipelines later via Project Settings > Deploys.
Step 7 — Monitor and confirm the release
After switching traffic, compare error rates and latency between the pre-switch and post-switch windows. A spike in errors immediately after the switch usually means the green code hit a production condition the validation tests didn’t cover.
Monitor database query patterns: new queries from the green code may create unexpected load. Connection pool usage and slow query logs are the first places to check.
Each switch is timestamped in CircleCI’s Deploys dashboard, so release events line up with metric changes in Prometheus, Datadog, or Grafana.
Keep the blue environment running for a defined soak period after the switch: 30 minutes, an hour, whatever the team’s risk tolerance dictates. Only scale it down once the team is confident the release is stable and rollback won’t be needed. While the blue Deployment is still running, rollback takes seconds. After it’s scaled down, rolling back requires redeploying the previous image.
To check the current state of both environments:
kubectl get pods -l app=blue-green-tutorial-app -L version
This shows all pods with their version label, making it clear which environment is running what.
How CircleCI enables blue-green deployments
CircleCI’s role in this pipeline isn’t just running the steps. The approval job is what turns a blue-green deploy into a gated release: code reaches production infrastructure on every push to main, but traffic only shifts when an engineer clicks Approve in the CircleCI UI. That separation between deploy and release is what makes blue-green’s rollback fast. The previous version is still running, so reverting is a Service selector flip, not a redeploy. Deploy markers track each release in the Deploys dashboard, and the rollback pipeline turns reverts into a one-click action from that same dashboard.
Blue-green is one deployment strategy among many. Other deployment strategies make different trade-offs around speed, risk, and infrastructure cost. The same CircleCI primitives (approval gates, deploy markers, rollback pipelines) apply across all of them.
When to use blue-green (and when not to)
Blue-green makes sense when:
- Instant rollback is a hard requirement (financial services, e-commerce checkout, healthcare).
- The team can afford double the compute cost during the deployment window.
- Pre-switch validation in a production environment matters.
- Releases are infrequent and high-stakes.
Blue-green is the wrong choice when:
- Infrastructure budget is tight (canary uses a fraction of the resources).
- The team deploys many times per day (two-environment overhead doesn’t pay off at high frequency).
- Percentage-based traffic shifting and metric-driven promotion is needed (that’s canary territory).
For a comparison of strategies, see rolling deployments on Kubernetes.
Conclusion
This tutorial built a complete blue-green pipeline on Kubernetes: deploy to a parallel environment, validate against production infrastructure, hold for human approval, switch traffic, and roll back from the dashboard if something breaks. The whole pipeline lives as YAML in the repository, runs on CircleCI’s free tier, and works against GKE, EKS, AKS, or any self-managed Kubernetes cluster.
To start building, sign up for a free CircleCI account and adapt the pipeline config to your own project.