Blue-green deployment with Docker Compose for PHP monoliths (Nginx, DB & healthcheck)

Deploy & DevOps · By · Published on · 9 min read

SFP connectors and fiber optic cables connected to a rack network device
Infrastructure and connectivity image, suitable for deployment and operations tutorials. Photo: Brett Sayles / Pexels

This tutorial shows how to implement a blue-green deployment with Docker Compose for a PHP monolith. You’ll run Nginx in front, two PHP-FPM containers (Blue and Green), a shared database, HTTP healthchecks, and a script to switch traffic without downtime.

Prerequisites and blue-green architecture with Compose

Blue‑green keeps two app versions running: Blue (active) and Green (staged). We switch traffic only when Green is healthy. If something goes wrong, we can quickly revert to Blue.

In this blue-green deployment with Docker Compose for a PHP monolith you’ll have the components listed below. Install Docker and Docker Compose before starting. If needed, follow: install Docker and Docker Compose.

The strategy requires migration compatibility. Plan database changes in two phases so rollback is possible. Never run destructive migrations without a rollback plan.

  • A single Nginx instance exposing port 80.
  • Two PHP-FPM containers: app_blue and app_green.
  • A shared database (e.g., MariaDB).
  • A shared volume for app uploads/files.
  • HTTP healthchecks: /health, /health-blue and /health-green.

Project folder structure

Organize files like this. We recommend versioning the infrastructure together with the app. Adjust names to match your project.

We’ll create a script to switch traffic by copying the active upstream configuration.

project/
├── .env
├── docker-compose.yml
├── app/
│   ├── Dockerfile
│   ├── src/
│   │   ├── public/
│   │   │   └── health.php
│   │   └── index.php  # se usar front controller
├── nginx/
│   └── conf.d/
│       ├── nginx.conf
│       ├── upstream_blue.conf
│       └── upstream_green.conf
└── deploy/
    └── switch_traffic.sh

PHP monolith Dockerfile (PHP-FPM)

We use a small Dockerfile to build dependencies with Composer and run PHP-FPM. Adapt extensions and commands to your application. To reduce image size and layers, see also lightweight Docker images with multistage.

# app/Dockerfile
FROM php:8.2-fpm-alpine AS runtime

# Extensões comuns — ajuste conforme seu banco (pdo_mysql ou pdo_pgsql)
RUN apk add --no-cache git oniguruma-dev libzip-dev \
    && docker-php-ext-install pdo_mysql opcache

# Composer para instalar dependências quando houver composer.json
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
COPY src/ .

# Instale dependências do PHP se existir composer.json
RUN if [ -f composer.json ]; then \
      composer install --no-interaction --no-dev --prefer-dist --optimize-autoloader; \
    fi \
    && chown -R www-data:www-data /var/www/html

EXPOSE 9000
CMD ["php-fpm", "-F"]

Nginx configuration with Blue/Green upstreams and health endpoints

Nginx forwards requests to the upstream declared in upstream_active.conf. We also expose fixed routes to check Blue and Green separately. Switching traffic replaces the active upstream file and reloads Nginx.

# nginx/conf.d/nginx.conf

# Upstreams fixos para health dos dois lados
upstream php_blue  { server app_blue:9000; }
upstream php_green { server app_green:9000; }

# Upstream ativo — definido por upstream_active.conf (criado pelo switch)
# Este arquivo deve declarar: upstream php_upstream { server app_blue:9000; } ou app_green
include /etc/nginx/conf.d/upstream_active.conf;

server {
    listen 80;
    server_name _;

    # Health ativo (aponta para php_upstream definido via include)
    location = /health {
        fastcgi_pass php_upstream;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/health.php;
    }

    # Healths dedicados
    location = /health-blue {
        fastcgi_pass php_blue;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/health.php;
    }
    location = /health-green {
        fastcgi_pass php_green;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/health.php;
    }

    # Roteie tudo para seu front controller
    location / {
        try_files $uri /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass php_upstream; # definido no include
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
    }
}

# nginx/conf.d/upstream_blue.conf
upstream php_upstream { server app_blue:9000; }

# nginx/conf.d/upstream_green.conf
upstream php_upstream { server app_green:9000; }

docker-compose.yml: Blue/Green, DB, volumes and healthcheck

The Compose file brings up Nginx, both app instances and the database. We use a shared volume for uploads. Nginx’s healthcheck queries the local endpoints. To publish images to a registry, see: private Docker registry.

# docker-compose.yml
version: "3.9"

services:
  nginx:
    image: nginx:1.25-alpine
    depends_on:
      - app_blue
      - app_green
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./nginx/conf.d/upstream_active.conf:/etc/nginx/conf.d/upstream_active.conf:ro
      # Diretório de uploads/arquivos compartilhados
      - app_shared:/var/www/html/storage
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost/health >/dev/null && wget -qO- http://localhost/health-blue >/dev/null && wget -qO- http://localhost/health-green >/dev/null"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks: [web]

  app_blue:
    build:
      context: ./app
    container_name: app_blue
    environment:
      APP_COLOR: blue
      APP_VERSION: v1
      DB_HOST: db
      DB_NAME: app
      DB_USER: app
      DB_PASS: secret
    volumes:
      - app_shared:/var/www/html/storage
    networks: [web]

  app_green:
    build:
      context: ./app
    container_name: app_green
    environment:
      APP_COLOR: green
      APP_VERSION: v2
      DB_HOST: db
      DB_NAME: app
      DB_USER: app
      DB_PASS: secret
    volumes:
      - app_shared:/var/www/html/storage
    networks: [web]

  db:
    image: mariadb:10.11
    environment:
      MARIADB_DATABASE: app
      MARIADB_USER: app
      MARIADB_PASSWORD: secret
      MARIADB_ROOT_PASSWORD: rootsecret
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks: [web]

volumes:
  db_data:
  app_shared:

networks:
  web:

Health check endpoint in PHP (with DB check)

The health.php returns 200 when the app and the database respond. It also exposes version and color, useful to verify the switch. Adjust the driver and DSN if you use another database (e.g., PostgreSQL).

<?php
// app/src/public/health.php
header('Content-Type: application/json');

$color   = getenv('APP_COLOR') ?: 'unknown';
$version = getenv('APP_VERSION') ?: 'unknown';
$dbHost  = getenv('DB_HOST') ?: 'db';
$dbName  = getenv('DB_NAME') ?: 'app';
$dbUser  = getenv('DB_USER') ?: 'app';
$dbPass  = getenv('DB_PASS') ?: 'secret';

$status = [
  'ok' => true,
  'color' => $color,
  'version' => $version,
  'db' => 'unknown'
];

try {
  $pdo = new PDO("mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4", $dbUser, $dbPass, [
    PDO::ATTR_TIMEOUT => 2,
  ]);
  $pdo->query('SELECT 1');
  $status['db'] = 'up';
} catch (Throwable $e) {
  http_response_code(503);
  $status['ok'] = false;
  $status['db'] = 'down';
  $status['error'] = 'db';
}

echo json_encode($status);

Bring up the environment, do the first (Blue) deploy and prepare Green

Now bring the services up, point the active upstream to Blue and validate. Then publish Green, validate it, and leave it ready to receive traffic. If you have migrations, apply them in a way compatible with both versions.

# 1) Gerar upstream ativo inicial (Blue)
cp -f nginx/conf.d/upstream_blue.conf nginx/conf.d/upstream_active.conf

# 2) Subir stack
docker compose up -d --build db app_blue app_green nginx

# 3) Verificar saúde
curl -s http://localhost/health | jq .
curl -s http://localhost/health-blue | jq .
curl -s http://localhost/health-green | jq .

# 4) Popular DB, seeds e migrações (exemplo genérico)
# docker compose exec app_blue php artisan migrate --force   # se usar Laravel
# docker compose exec app_blue php artisan db:seed --force

# 5) Preparar versão Green (atualize código e re-build)
# Atualize app/src/ com sua nova versão
docker compose build app_green

# 6) Validar saúde da Green
curl -s http://localhost/health-green | jq .

Switch traffic and safe rollback

We use a simple script to swap the upstream and reload Nginx. In this blue-green deployment with Docker Compose for a PHP monolith, the switch is fast and reversible. For post-deploy monitoring, see Prometheus & Grafana with Docker Compose.

  • The script accepts blue or green.
  • From Nginx’s perspective the switch is atomic: reload is fast.
  • Rollback is just switching back to the other color.
  • Keep database migrations compatible with both versions.
# deploy/switch_traffic.sh
#!/usr/bin/env bash
set -euo pipefail
COLOR="${1:-}"
if [[ "$COLOR" != "blue" && "$COLOR" != "green" ]]; then
  echo "Uso: $0 blue|green"; exit 1
fi
cp -f "nginx/conf.d/upstream_${COLOR}.conf" "nginx/conf.d/upstream_active.conf"
docker compose exec -T nginx nginx -s reload
echo "Tráfego direcionado para: $COLOR"

# Exemplos de uso:
# bash deploy/switch_traffic.sh green   # troca para Green
# bash deploy/switch_traffic.sh blue    # rollback para Blue

How to confirm it worked and troubleshooting

Confirm the switch by inspecting the health output. It should show the expected color and version. Also check logs and latency after the deploy. Common issues and fixes listed below.

To automate the whole flow in CI/CD, integrate build, health validation and the switch in your pipeline. Example: automatic deploy pipeline.

  • Check: curl http://YOUR_HOST/health should show {"color":"green","version":"..."} after the switch.
  • Missing uploads: ensure both containers mount the same uploads volume (app_shared).
  • User sessions: store sessions in a shared backend (e.g., Redis) to avoid logouts during the switch.
  • Migrations: make schema changes in steps compatible with both versions; add new columns before using them in code.
  • Static assets: serve via CDN/object storage or mount a shared volume for public generated files.
  • TLS: terminate TLS on the host or add a TLS proxy in front; this example uses HTTP for simplicity.

Frequently asked questions

Does blue-green work without Kubernetes?

Yes. This tutorial uses only Docker Compose, Nginx and a script to swap the upstream. You keep both versions running and switch traffic by reloading Nginx.

How do I perform a fast rollback?

Run the script targeting the previous color. Since both versions are active, the rollback is immediate. Still, monitor errors and latency after reverting.

What about database migrations in blue-green?

Apply migrations in a way that’s compatible with both versions. Avoid destructive changes until the old version is retired. Test the new schema with Green before switching traffic.

How do I handle sessions and cache?

Keep sessions and cache in a shared backend like Redis. That prevents users from losing their session during the Blue↔Green switch.

Can I use PostgreSQL instead of MariaDB?

Yes. Update the DB image, environment variables and the DSN in health.php (pdo_pgsql). The rest of the blue-green flow stays the same.

How should I serve static files efficiently?

Serve static assets from Nginx backed by a shared volume or a CDN. Build assets during the image build and make them available to both versions before switching.

Want to adapt blue-green to your PHP monolith?

I can help review your Compose setup, plan safe migrations and automate traffic switching.

Talk about blue-green deploy

See also