DevOps

Blue-Green Deployment of Laravel Applications with Docker and Redis: Traffic Switching Without Session Loss

Ruslan Ismailov Published 14 min read
B

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:

  1. Identify the currently active stack (blue or green).
  2. Bring up the new stack (the opposite one).
  3. Wait for successful health checks on the new stack.
  4. Run database migrations (backward-compatible).
  5. Switch the nginx upstream to the new stack (atomic operation).
  6. Wait for existing connections on the old stack to finish.
  7. 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 →