Blue-Green Deployment of Laravel Applications with Docker and Redis: Traffic Switching Without Session Loss
Introduction: Why Laravel Needs Blue-Green Deployment
Standard rolling updates work well for stateless services, but Laravel applications are rarely fully stateless. File-based cache, local sessions, queues, and artisan commands that cannot run in parallel across two versions simultaneously — all of this makes rolling updates risky. While one container is being updated, old and new code may simultaneously handle requests from the same user, leading to session deserialization errors, migration conflicts, and unpredictable behavior.
Blue-green deployment solves this problem in a fundamentally different way: you maintain two identical environments — blue (current production) and green (new version). Traffic is switched atomically once the green environment is fully ready and has passed health checks. If something goes wrong, rollback takes seconds.
In this article, we'll walk through a complete blue-green deployment implementation for Laravel using Docker, Redis for sessions, and CI/CD via GitHub Actions. The target audience is PHP developers and DevOps engineers who want true zero-downtime deployment in production.
Blue-Green Environment Architecture with Docker Compose
The core idea: nginx acts as the sole entry point and knows which stack is currently active. Two stacks — blue and green — run in parallel, but only one receives traffic.
Project structure:
.
├── docker-compose.blue.yml
├── docker-compose.green.yml
├── docker-compose.nginx.yml
├── nginx/
│ ├── nginx.conf
│ ├── upstream-blue.conf
│ └── upstream-green.conf
├── scripts/
│ ├── switch.sh
│ └── healthcheck.sh
└── .env.blue
.env.green
The docker-compose.blue.yml file describes the "blue" stack:
version: '3.9'
services:
app_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_blue
env_file: .env.blue
networks:
- app_net
depends_on:
- redis
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
worker_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_worker_blue
env_file: .env.blue
command: php artisan queue:work --sleep=3 --tries=3
networks:
- app_net
restart: unless-stopped
networks:
app_net:
external: true
The docker-compose.green.yml file is identical but uses the _green suffix for all containers. Nginx reads the upstream configuration from a file that we swap during the switch:
# nginx/nginx.conf
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/conf.d/upstream.conf;
server {
listen 80;
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
location / {
proxy_pass http://laravel_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
# nginx/upstream-blue.conf
upstream laravel_upstream {
server laravel_blue:9000;
}
# nginx/upstream-green.conf
upstream laravel_upstream {
server laravel_green:9000;
}
Session Management with Redis: Why Sticky Sessions Are an Anti-Pattern
Sticky sessions (binding a user to a specific container via cookie or IP) might seem like a simple solution, but they create serious problems in blue-green deployments. When traffic is switched, a user "stuck" to the blue container suddenly ends up on green — and their session is lost because it was stored locally in the old container's filesystem.
The correct solution is to store sessions in Redis, which is shared between both stacks. When traffic switches from blue to green, the user won't even notice the transition: their session is read from Redis and remains valid.
Configuration in .env:
SESSION_DRIVER=redis
SESSION_LIFETIME=120
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=your_secure_password
REDIS_SESSION_DB=1
CACHE_DRIVER=redis
REDIS_CACHE_DB=2
In config/session.php, make sure the correct connection is used:
'connection' => env('REDIS_SESSION_CONNECTION', 'session'),
In config/database.php, add a dedicated Redis connection for sessions:
'redis' => [
'session' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '1'),
],
],
Redis must run separately from both stacks and be accessible to both via a shared Docker network. This is critical: Redis should not be part of either the blue or green stack — it belongs to the external infrastructure only.
Step-by-Step Traffic Switching Scenario
The complete switching scenario looks like this:
- Identify the currently active stack (blue or green).
- Bring up the new stack (the opposite one).
- Wait for successful health checks on the new stack.
- Run database migrations (backward-compatible).
- Switch the nginx upstream to the new stack (atomic operation).
- Wait for existing connections on the old stack to finish.
- Stop the old stack (keep it running for a few more minutes for quick rollback).
The switching script scripts/switch.sh:
#!/bin/bash
set -euo pipefail
ACTIVE_COLOR_FILE="/var/run/laravel-active"
NGINX_CONF_DIR="/etc/nginx/conf.d"
NGINX_CONTAINER="nginx_proxy"
# Determine the currently active stack
if [ -f "$ACTIVE_COLOR_FILE" ]; then
CURRENT=$(cat "$ACTIVE_COLOR_FILE")
else
CURRENT="blue"
fi
if [ "$CURRENT" == "blue" ]; then
NEXT="green"
else
NEXT="blue"
fi
echo "[deploy] Current: $CURRENT → Next: $NEXT"
# Bring up the new stack
docker compose -f docker-compose.${NEXT}.yml up -d --build
# Wait for health checks on the new stack
echo "[deploy] Waiting for health checks..."
MAX_RETRIES=30
RETRY=0
while ! docker inspect --format='{{.State.Health.Status}}' "laravel_${NEXT}" | grep -q 'healthy'; do
RETRY=$((RETRY+1))
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
echo "[deploy] ERROR: Health check failed. Rolling back."
docker compose -f docker-compose.${NEXT}.yml down
exit 1
fi
sleep 5
done
echo "[deploy] Health check passed."
# Run migrations
echo "[deploy] Running migrations..."
docker exec "laravel_${NEXT}" php artisan migrate --force
# Switch nginx upstream
cp "${NGINX_CONF_DIR}/upstream-${NEXT}.conf" "${NGINX_CONF_DIR}/upstream.conf"
docker exec "$NGINX_CONTAINER" nginx -s reload
echo "$NEXT" > "$ACTIVE_COLOR_FILE"
echo "[deploy] Switched to $NEXT."
# Graceful shutdown of the old stack (wait 30 seconds for connections to finish)
echo "[deploy] Stopping $CURRENT stack in 30s..."
sleep 30
docker compose -f docker-compose.${CURRENT}.yml stop
echo "[deploy] Done."
For a quick rollback, simply repeat the switch in the opposite direction. Since the old stack is only stopped (not removed), rollback takes seconds:
#!/bin/bash
# scripts/rollback.sh
set -euo pipefail
ACTIVE_COLOR_FILE="/var/run/laravel-active"
CURRENT=$(cat "$ACTIVE_COLOR_FILE")
if [ "$CURRENT" == "blue" ]; then
PREV="green"
else
PREV="blue"
fi
echo "[rollback] Activating $PREV..."
docker compose -f docker-compose.${PREV}.yml start
cp "nginx/upstream-${PREV}.conf" "/etc/nginx/conf.d/upstream.conf"
docker exec nginx_proxy nginx -s reload
echo "$PREV" > "$ACTIVE_COLOR_FILE"
echo "[rollback] Done. Active: $PREV"
Database Migrations in a Blue-Green Strategy
The most challenging aspect of blue-green deployment for Laravel is migrations. At the moment of switching, both stacks must work with the same database, so the new schema must be compatible with the old code.
Use the expand/contract pattern:
- Expand (deploy N): add a new column as nullable or with a default value. The old code ignores it; the new code populates it.
- Contract (deploy N+1): drop the old column once you've confirmed that all traffic is flowing through the new code.
Example of a backward-compatible migration: renaming the column user_name to username.
Step 1 — Expand (deploy N):
// database/migrations/2024_01_01_add_username_column.php
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
// Add the new column while keeping the old one
$table->string('username')->nullable()->after('user_name');
});
// Copy data
DB::statement('UPDATE users SET username = user_name WHERE username IS NULL');
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('username');
});
}
Step 2 — Contract (deploy N+1, after full traffic switch):
// database/migrations/2024_01_15_drop_user_name_column.php
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('user_name');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('user_name')->nullable();
});
}
Never run destructive migrations in the same deploy as a traffic switch. Always separate expand and contract into two distinct releases.
Automating the Switch with CI/CD
Let's integrate blue-green deployment into GitHub Actions. The pipeline consists of three stages: image build, deploy and traffic switch, and optional rollback on failure.
# .github/workflows/deploy.yml
name: Blue-Green Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: type=sha,prefix=,suffix=,format=short
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
export APP_IMAGE=ghcr.io/${{ github.repository }}
export APP_VERSION=${{ needs.build.outputs.image_tag }}
cd /opt/laravel-app
# Pull the new image
docker pull ${APP_IMAGE}:${APP_VERSION}
# Run the switch
bash scripts/switch.sh
script_stop: true
rollback-on-failure:
needs: deploy
runs-on: ubuntu-latest
if: failure()
steps:
- name: Rollback
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: bash /opt/laravel-app/scripts/rollback.sh
Monitoring During the Switch
Deployment automation is great, but without monitoring you'll hear about problems from users rather than from the system. Here's what to track during the switch:
- HTTP error rate: a sharp spike in 5xx errors after the switch is a signal for immediate rollback.
- Latency P95/P99: response time degradation indicates issues with the new version.
- Redis connections: make sure both stacks connect to Redis correctly without connection contention.
- Queue job failures: queue errors often surface later than HTTP errors.
- Database slow queries: new migrations can introduce unexpected load.
A simple bash script for monitoring the error rate during the switch:
#!/bin/bash
# scripts/monitor-switch.sh
set -euo pipefail
THRESHOLD=5 # maximum 5% error rate
DURATION=120 # monitor for 2 minutes after the switch
INTERVAL=10
echo "[monitor] Watching error rate for ${DURATION}s..."
END=$((SECONDS + DURATION))
while [ $SECONDS -lt $END ]; do
# Get stats from nginx access log
TOTAL=$(docker exec nginx_proxy awk '{print $9}' /var/log/nginx/access.log | wc -l)
ERRORS=$(docker exec nginx_proxy awk '$9 >= 500 {count++} END {print count+0}' /var/log/nginx/access.log)
if [ "$TOTAL" -gt 0 ]; then
ERROR_RATE=$(echo "scale=2; $ERRORS * 100 / $TOTAL" | bc)
echo "[monitor] Error rate: ${ERROR_RATE}% (${ERRORS}/${TOTAL})"
if (( $(echo "$ERROR_RATE > $THRESHOLD" | bc -l) )); then
echo "[monitor] ERROR RATE TOO HIGH! Initiating rollback..."
bash /opt/laravel-app/scripts/rollback.sh
exit 1
fi
fi
sleep $INTERVAL
done
echo "[monitor] Switch successful. No degradation detected."
Complete Docker Compose Configuration Example
Here is a complete production environment configuration with comments. The docker-compose.nginx.yml file is a separate, persistent nginx service:
# docker-compose.nginx.yml
version: '3.9'
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx_proxy
ports:
- "80:80"
- "443:443"
volumes:
# Nginx configuration
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
# Upstream config — swapped during traffic switch
- /etc/nginx/conf.d:/etc/nginx/conf.d
# SSL certificates
- ./ssl:/etc/nginx/ssl:ro
# Logs for monitoring
- nginx_logs:/var/log/nginx
networks:
- app_net
restart: unless-stopped
# Redis — shared between both stacks
redis:
image: redis:7.2-alpine
container_name: redis_shared
command: >
redis-server
--requirepass ${REDIS_PASSWORD}
--maxmemory 512mb
--maxmemory-policy allkeys-lru
--save 60 1000
volumes:
- redis_data:/data
networks:
- app_net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
redis_data:
nginx_logs:
networks:
app_net:
name: app_net
driver: bridge
The docker-compose.blue.yml file with a full Laravel configuration:
# docker-compose.blue.yml
version: '3.9'
services:
app_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_blue
env_file: .env.blue
working_dir: /var/www
volumes:
# Only storage/logs are mounted externally
- storage_blue:/var/www/storage
networks:
- app_net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost/health || exit 1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
labels:
- "stack=blue"
- "app=laravel"
worker_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_worker_blue
env_file: .env.blue
working_dir: /var/www
# One worker per stack; during the switch, the old one finishes its jobs
command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
volumes:
- storage_blue:/var/www/storage
networks:
- app_net
restart: unless-stopped
labels:
- "stack=blue"
- "app=laravel-worker"
scheduler_blue:
image: ${APP_IMAGE}:${APP_VERSION}
container_name: laravel_scheduler_blue
env_file: .env.blue
working_dir: /var/www
# Scheduler — run only in the active stack!
# Controlled via SCHEDULER_ENABLED in .env
command: |
sh -c 'while true; do
if [ "$${SCHEDULER_ENABLED}" = "true" ]; then
php artisan schedule:run;
fi;
sleep 60;
done'
networks:
- app_net
restart: unless-stopped
volumes:
storage_blue:
networks:
app_net:
external: true
Note the SCHEDULER_ENABLED variable in the scheduler configuration. In .env.blue, set SCHEDULER_ENABLED=true only for the active stack. Before switching, the script updates this value in the correct order so the scheduler runs in only one stack at a time.
Dockerfile for the Laravel application:
# Dockerfile
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache \
curl \
libpng-dev \
libzip-dev \
redis \
&& docker-php-ext-install pdo_mysql zip gd \
&& pecl install redis \
&& docker-php-ext-enable redis
WORKDIR /var/www
# Composer
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
# Dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# Application code
COPY . .
RUN composer dump-autoload --optimize \
&& php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache \
&& chown -R www-data:www-data storage bootstrap/cache
# Health check endpoint
RUN echo ' /var/www/public/health.php
EXPOSE 9000
CMD ["php-fpm"]
Conclusion
Blue-green deployment with Docker and Redis is not just a trendy architectural practice — it's a real way to achieve zero-downtime deployment for Laravel applications in production. The key principles we covered:
- Two identical stacks, between which nginx switches traffic atomically.
- Redis as the unified session store — users don't notice the switch.
- Backward-compatible migrations using the expand/contract pattern — no destructive schema changes at the moment of switching.
- Automation via GitHub Actions with automatic rollback on deploy failure.
- Error rate monitoring for several minutes after the switch.
Start small: set up Redis sessions and a simple switching script. Once you're comfortable with the mechanics, integrate CI/CD and add monitoring. This approach gives you confidence in your deployments and the ability to roll back in seconds, not minutes.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →