Zero-downtime deployment — deploying without a visible outage
Concept
Zero-downtime deployment — deploying new code while the application continues serving requests without interruption or error.
The problem: A naive deployment stops the server, overwrites files, restarts. During this window, users get errors. For high-traffic apps, even seconds of downtime is unacceptable.
What causes downtime during deployments:
- Server restart or stop.
- Database migrations that lock tables.
- New code deployed before the server restarts (mixed old/new code in one request).
composer installrunning while web server is serving requests from incomplete vendor directory.
Atomic deployments with symlinks (the classic approach):
- Upload new code to a NEW directory (
releases/20240612). - Run
composer install,artisan migrate,artisan optimize. - Atomically swap the symlink:
current/→releases/20240612. - PHP-FPM reads
current/— now serves new code with zero downtime. - Keep last 5 releases, delete older ones.
Tools: Laravel Envoyer, Capistrano, Deployer. All implement the atomic symlink approach.
Database migrations in zero-downtime: The hardest part. The new code must work with the OLD schema (during the deployment window, old code still runs) AND with the NEW schema. Approach: multi-step migrations.
Blue-green deployments (separate concept): Run two identical environments. Zero downtime via load balancer switch. See eng-18-009.
Code Example
# Atomic deployment script (simplified Envoyer/Deployer approach)
#!/bin/bash
RELEASE_DIR="/var/www/releases/$(date +%Y%m%d%H%M%S)"
CURRENT_LINK="/var/www/current"
SHARED_DIR="/var/www/shared"
# 1. Upload code to new release directory
rsync -az ./dist/ "$RELEASE_DIR/"
# 2. Link shared files (storage, .env)
ln -nfs "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
ln -nfs "$SHARED_DIR/storage" "$RELEASE_DIR/storage"
# 3. Install dependencies in release directory
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader
# 4. Run migrations BEFORE swap (must be backward-compatible!)
php artisan migrate --force
# 5. Optimize (cache config, routes, views)
php artisan optimize
# 6. ATOMIC SWAP — this is the zero-downtime moment
ln -nfs "$RELEASE_DIR" "$CURRENT_LINK" # atomic: one syscall
# 7. Reload PHP-FPM gracefully (drains existing requests, starts new workers)
php-fpm -t && kill -USR2 $(cat /var/run/php-fpm.pid)
# 8. Clean up old releases (keep last 5)
ls -t /var/www/releases/ | tail -n +6 | xargs -I {} rm -rf /var/www/releases/{}
echo "Deployed to: $RELEASE_DIR"<?php
// SAFE MIGRATION for zero-downtime:
// Adding a NOT NULL column requires multiple deployments
// WRONG — blocks table during deploy, breaks old code
Schema::table('orders', function (Blueprint $table) {
$table->string('reference_code')->notNull(); // old code doesn't send this!
});
// CORRECT — three deployments:
// Deploy 1: Add nullable column (backward-compatible)
Schema::table('orders', function (Blueprint $table) {
$table->string('reference_code')->nullable(); // old code works fine
});
// Deploy 2: Backfill existing rows (queue job, no downtime)
// Deploy 3: Add NOT NULL constraint after all rows have values
Schema::table('orders', function (Blueprint $table) {
$table->string('reference_code')->notNull()->change();
});