Most Yii2 applications I have encountered in the wild get deployed the same way: SSH into the server, run git pull, maybe run a migration, clear the cache, and refresh the browser to see if anything broke. If the migration takes thirty seconds, those thirty seconds belong to your users in the form of errors.
There is a better way. With Docker and a two-container swap behind Nginx, you can release new code with no gap in availability. The old container keeps serving requests while the new one starts, and the switch happens at the proxy level once the new container passes its health check.
The basic idea
You run two containers on the same host, on different ports. Nginx proxies to only one of them at a time. When you deploy, you start the new container on the idle port, wait for it to be healthy, then tell Nginx to switch. The old container gets a grace period to finish existing requests, then stops.
Here is what the relevant part of your docker-compose.yml looks like:
services:
app_blue:
image: yourapp:${VERSION}
ports:
- "9001:9000"
environment:
- APP_ENV=production
healthcheck:
test: ["CMD", "php", "yii", "health/check"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
app_green:
image: yourapp:${VERSION}
ports:
- "9002:9000"
environment:
- APP_ENV=production
healthcheck:
test: ["CMD", "php", "yii", "health/check"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
The Nginx config
Keep the active upstream in a small include file that you swap atomically:
upstream app_active {
include /etc/nginx/conf.d/active_upstream.conf;
keepalive 32;
}
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://app_active;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 60s;
}
}
The active_upstream.conf file contains one line: server 127.0.0.1:9001; for blue or server 127.0.0.1:9002; for green.
The deploy script
#!/usr/bin/env bash
set -euo pipefail
VERSION=$1
CURRENT=$(cat /var/deploy/active_slot) # "blue" or "green"
NEXT=$( [ "$CURRENT" = "blue" ] && echo "green" || echo "blue" )
NEXT_PORT=$( [ "$NEXT" = "blue" ] && echo "9001" || echo "9002" )
echo "Deploying $VERSION to $NEXT slot (port $NEXT_PORT)"
# Pull and start new container
VERSION=$VERSION docker compose up -d app_$NEXT
# Wait for health check
echo "Waiting for health check..."
until docker inspect --format "{{.State.Health.Status}}" app_$NEXT | grep -q healthy; do
sleep 3
done
# Run migrations (idempotent, fast)
docker exec app_$NEXT php yii migrate --interactive=0
# Switch Nginx
echo "server 127.0.0.1:$NEXT_PORT;" > /etc/nginx/conf.d/active_upstream.conf
nginx -s reload
# Save state
echo $NEXT > /var/deploy/active_slot
# Stop old container after grace period
sleep 10
docker compose stop app_$CURRENT
echo "Deploy complete. Active slot: $NEXT"
The Yii2 health check command
You need a console command that Docker can call for the health check. It should verify the database is reachable and any critical services are up:
<?php
namespace consolecontrollers;
use yiiconsoleController;
use Yii;
class HealthController extends Controller
{
public function actionCheck(): int
{
// Check DB
Yii::$app->db->createCommand("SELECT 1")->execute();
// Check cache
Yii::$app->cache->set("health_check", time(), 60);
return 0;
}
}
Migrations during zero-downtime deploys
This is where people run into trouble. If your migration drops a column that the old code still reads, the old container will start throwing errors the moment the migration runs. The solution is backward-compatible migrations: add columns before deploying code that uses them, remove columns only after the code that used them is already gone from all containers.
For most Yii2 projects this is not a problem in practice. Migrations are usually additive, and the window between the migration running and the old container stopping is under ten seconds.
What this gets you
You get releases you can run at any hour without scheduling a maintenance window. You get a clear record of what version is running on which slot. You get rollback by re-running the deploy script with the previous version number. And your users get an uninterrupted experience during what used to be the riskiest part of your week.