Journal

Zero-Downtime Deployments with Nginx and PHP-FPM

Deploying a PHP app shouldn’t mean a few seconds of downtime while things restart. With the right setup, you can push code to production without dropping a single request. Here’s how.

The Deployment Problem

A naive deployment looks like this:

  1. Pull new code
  2. Run composer install
  3. Restart PHP-FPM

During step 3, in-flight requests get killed. New requests fail until the new workers are ready. That’s your downtime window — usually 1-3 seconds, but enough to cause errors for active users.

Graceful Reloads

The fix is using reload instead of restart:

sudo systemctl reload php8.3-fpm

A reload sends SIGUSR2 to the FPM master process. It:

  1. Finishes all in-flight requests on existing workers
  2. Spawns new workers with the updated code
  3. Kills old workers after they finish

No dropped requests. The master process stays alive throughout.

Nginx Graceful Reload

sudo systemctl reload nginx

Nginx uses a similar strategy — new connections go to new workers, old workers finish their current requests then exit.

Unix Sockets vs TCP

Use Unix sockets for local PHP-FPM communication. They’re faster (no TCP overhead) and simpler to manage:

# nginx.conf
upstream php {
    server unix:/run/php/php8.3-fpm.sock;
}
; pool.d/www.conf
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

TCP sockets (127.0.0.1:9000) add unnecessary overhead when Nginx and PHP-FPM are on the same machine. Reserve TCP for setups where they run on different hosts.

PHP-FPM Pool Tuning

The process manager controls how FPM handles worker processes. Three modes:

Static

pm = static
pm.max_children = 50

Fixed number of workers. Predictable memory usage. Best when you know your traffic patterns and have enough RAM.

Formula: max_children = available_memory / average_worker_memory

A typical PHP worker uses 30-50MB. On a 2GB server with 1.5GB available for PHP:

1500MB / 40MB = 37 workers

Dynamic

pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20

Scales workers up and down based on demand. Good for variable traffic but introduces latency when scaling up.

Ondemand

pm = ondemand
pm.max_children = 50
pm.process_idle_timeout = 10s

Spawns workers only when requests arrive. Minimum memory usage at idle, but the first request after idle pays a startup cost. Good for low-traffic sites on shared servers.

For zero-downtime deployments, use static or dynamic. Ondemand’s worker churn can complicate graceful reloads.

The Deployment Script

Putting it all together with a deployer-style directory structure:

/var/www/myapp/
├── current -> releases/20260115_120000
├── releases/
│   ├── 20260115_120000/
│   └── 20260114_090000/
└── shared/
    ├── .env
    └── storage/
#!/bin/bash
set -euo pipefail

APP_DIR="/var/www/myapp"
RELEASE="$(date +%Y%m%d_%H%M%S)"
RELEASE_DIR="$APP_DIR/releases/$RELEASE"

# Clone and build
git clone --depth 1 git@github.com:you/myapp.git "$RELEASE_DIR"
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader --no-interaction

# Link shared resources
ln -sf "$APP_DIR/shared/.env" "$RELEASE_DIR/.env"
ln -sf "$APP_DIR/shared/storage" "$RELEASE_DIR/storage"

# Run migrations
php artisan migrate --force

# Cache config and routes
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Atomic symlink swap
ln -sfn "$RELEASE_DIR" "$APP_DIR/current"

# Graceful reload — no dropped requests
sudo systemctl reload php8.3-fpm

# Clean up old releases (keep last 5)
cd "$APP_DIR/releases"
ls -1t | tail -n +6 | xargs -r rm -rf

echo "Deployed $RELEASE"

The key line is ln -sfn — it atomically swaps the symlink. Combined with systemctl reload, there’s no moment where requests fail.

OPcache Considerations

If you’re running with validate_timestamps=0 (you should be in production), FPM workers will serve cached opcodes from the old code until they’re recycled. The reload handles this, but there’s a brief window where some workers serve old code and some serve new.

For truly atomic cache invalidation:

// Add to your deploy script after the symlink swap
opcache_reset();

Or configure OPcache to use file paths that change with each release (the symlink approach handles this naturally if opcache.use_cwd=1).

Monitoring

After deployment, watch for issues:

# Check FPM status
sudo systemctl status php8.3-fpm

# Watch for errors in the log
journalctl -u php8.3-fpm -f --since "5 min ago"

# Check Nginx error log
tail -f /var/log/nginx/error.log

Zero-downtime deployment isn’t magic. It’s graceful reloads, atomic symlinks, and a process manager that knows how to hand off between old and new workers. Set it up once and forget about maintenance windows.