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:
- Pull new code
- Run
composer install - 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:
- Finishes all in-flight requests on existing workers
- Spawns new workers with the updated code
- 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.