Tutorial

Zero-Downtime Deployment: Strategies for PHP, Node.js, and Docker

May 28, 2026

Back to Blog
A modern alternative to cPanel, Plesk and CyberPanel — isolated, secure, AI-assisted.
Start free

Why Zero-Downtime Deployment Matters

Every second your application is offline costs money. For e-commerce platforms, the average cost of downtime ranges from $5,600 to $9,000 per minute. Even for smaller applications, every deployment window represents a moment when users encounter errors, forms lose data, and trust erodes. Zero-downtime deployment eliminates these risks entirely, ensuring your users never notice when you push new code to production.

$5,600
Average cost per minute of downtime
14hrs
Average annual downtime for traditional deployments

In this guide, we will cover three battle-tested strategies for zero-downtime deployments: blue-green deployment, rolling updates, and symlink switching. You will learn how to apply each technique to PHP, Node.js, and Docker applications with practical, copy-paste examples.

Understanding Deployment Downtime

Traditional deployment typically looks like this: stop the application, upload new files, install dependencies, run migrations, and start the application again. During this window, which can last anywhere from 30 seconds to several minutes, users see error pages, broken assets, or connection timeouts.

The Real Cost of Downtime: Beyond direct revenue loss, downtime damages SEO rankings (Google penalizes unreliable sites), breaks user trust, triggers SLA violations, and causes data loss when in-flight requests are dropped. A single botched deployment can undo months of growth.

Zero-downtime deployment solves this by ensuring there is always a healthy version of your application serving traffic. The transition from old to new happens atomically, meaning users are seamlessly shifted from one version to another without any interruption.

Strategy 1: Blue-Green Deployment

Blue-green deployment is the simplest zero-downtime strategy to understand. You maintain two identical production environments: "blue" and "green." At any time, only one environment serves live traffic. When you deploy, you update the idle environment and then switch the router to point to it.

Users
Load Balancer
Blue (Live)
Green (Idle)

How It Works

1
Deploy to Idle Environment: Upload your new code, install dependencies, run migrations, and warm caches on the idle environment (green). The live environment (blue) continues serving traffic undisturbed.
2
Test the New Version: Run health checks and smoke tests against the green environment. Verify that the application starts correctly, API endpoints respond, and database connections work.
3
Switch Traffic: Update the load balancer or reverse proxy to route all traffic from blue to green. This switch is nearly instantaneous.
4
Keep Blue as Rollback: The old blue environment remains untouched. If something goes wrong with the green deployment, switch back to blue in seconds.

Nginx Configuration for Blue-Green

Here is a practical nginx configuration that enables blue-green switching with a single file change:

# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
  # Blue environment
  server 127.0.0.1:8001;

  # Green environment (uncomment to switch)
  # server 127.0.0.1:8002;
}

# /etc/nginx/sites-available/app.conf
server {
  listen 80;
  server_name example.com;

  location / {
    proxy_pass http://app_backend;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

To switch from blue to green, simply comment out the blue server line, uncomment the green server line, and run nginx -s reload. The reload is graceful and does not drop existing connections.

Automated Blue-Green Switching Script

#!/bin/bash
# deploy-blue-green.sh
CURRENT=$(cat /opt/app/current_env)
if [ "$CURRENT" = "blue" ]; then
  TARGET="green"
  TARGET_PORT=8002
else
  TARGET="blue"
  TARGET_PORT=8001
fi

# Deploy to target
rsync -az --delete ./dist/ /opt/app/$TARGET/
cd /opt/app/$TARGET && npm install --production

# Health check
for i in {1..30}; do
  if curl -sf http://localhost:$TARGET_PORT/health; then
    echo "Health check passed"
    break
  fi
  sleep 1
done

# Switch traffic
sed -i "s/server 127.0.0.1:.*/server 127.0.0.1:$TARGET_PORT;/" \
  /etc/nginx/conf.d/upstream.conf
nginx -s reload
echo "$TARGET" > /opt/app/current_env
echo "Switched to $TARGET on port $TARGET_PORT"

Strategy 2: Symlink Switching for PHP Applications

Symlink switching is the go-to strategy for PHP applications. Since PHP re-reads files on every request (unless opcache is configured otherwise), changing where a symlink points effectively deploys new code instantly. Frameworks like Laravel Envoyer and Deployer use this exact pattern.

The Directory Structure

/var/www/myapp/
├── current -> /var/www/myapp/releases/20260528_143022
├── releases/
│ ├── 20260528_140512/
│ ├── 20260528_143022/ ← active release
│ └── 20260528_151045/ ← deploying
└── shared/
├── .env
├── storage/
└── uploads/

The current symlink always points to the active release directory. Your web server's document root points to /var/www/myapp/current/public. When you deploy, you create a new release directory, set everything up, and then atomically swap the symlink.

Complete PHP Deployment Script

#!/bin/bash
# deploy-php.sh - Zero-downtime PHP deployment
set -e

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

# Step 1: Create release directory
mkdir -p "$RELEASE_DIR"

# Step 2: Clone or rsync code
rsync -az --exclude='.env' --exclude='storage' \
  ./ "$RELEASE_DIR/"

# Step 3: Link shared resources
ln -sf "$APP_DIR/shared/.env" "$RELEASE_DIR/.env"
ln -sf "$APP_DIR/shared/storage" "$RELEASE_DIR/storage"
ln -sf "$APP_DIR/shared/uploads" "$RELEASE_DIR/public/uploads"

# Step 4: Install dependencies
cd "$RELEASE_DIR"
composer install --no-dev --optimize-autoloader --no-interaction

# Step 5: Run migrations
php artisan migrate --force

# Step 6: Cache configuration
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Step 7: Atomic symlink switch
ln -sfn "$RELEASE_DIR" "$APP_DIR/current_tmp"
mv -Tf "$APP_DIR/current_tmp" "$APP_DIR/current"

# Step 8: Reload PHP-FPM (graceful)
systemctl reload php8.3-fpm

# Step 9: Clear opcache
curl -sf http://localhost/opcache-reset.php || true

# Step 10: Clean old releases
cd "$APP_DIR/releases"
ls -dt */ | tail -n +$((KEEP_RELEASES+1)) | xargs rm -rf

echo "Deployed release $RELEASE successfully"
The Atomic Symlink Trick: The two-step symlink swap (ln -sfn + mv -Tf) is critical. A simple ln -sf actually deletes the old symlink first, then creates a new one — there is a brief moment with no symlink. Using mv -Tf performs an atomic rename at the filesystem level, ensuring zero gap.

Handling OPcache

PHP's OPcache caches compiled bytecode using the file's realpath. When you swap a symlink, OPcache may still serve the old compiled code. You have three options to handle this:

MethodApproachImpact
Reload PHP-FPMsystemctl reload php8.3-fpmGraceful
OPcache Reset ScriptHTTP endpoint calling opcache_reset()No restart
File-based Invalidationopcache.validate_timestamps=0 + opcache_invalidate()Complex
Restart PHP-FPMsystemctl restart php8.3-fpmBrief drop

Strategy 3: Rolling Updates with Docker

Docker makes zero-downtime deployment native through rolling updates. Instead of replacing all containers at once, Docker updates them one at a time, ensuring there is always at least one healthy container serving requests.

Docker Compose Rolling Update

# docker-compose.yml
version: '3.8'
services:
  app:
    image: myapp:latest
    deploy:
      replicas: 3
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    ports:
      - "3000:3000"
start-first vs stop-first: The order: start-first configuration tells Docker to start the new container before stopping the old one. This ensures there is always at least one container running. Use stop-first only when you cannot run two versions simultaneously (e.g., port conflicts without a load balancer).

Docker Swarm Rolling Updates

# Create a service with 3 replicas
$ docker service create --name myapp \
  --replicas 3 \
  --update-parallelism 1 \
  --update-delay 10s \
  --update-order start-first \
  --rollback-parallelism 0 \
  myapp:1.0

# Update to new version
$ docker service update --image myapp:2.0 myapp
myapp
overall progress: 3 out of 3 tasks
1/3: running [==================================>]
2/3: running [==================================>]
3/3: running [==================================>]
verify: Service converged

# Rollback if needed
$ docker service rollback myapp

Node.js Graceful Restart with PM2

Node.js applications present a unique challenge: a running Node process holds open connections and in-memory state. Simply killing and restarting the process drops active requests. PM2's cluster mode solves this by managing multiple worker processes and restarting them one at a time.

PM2 Ecosystem Configuration

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'myapp',
    script: './dist/server.js',
    instances: 'max', // Use all CPU cores
    exec_mode: 'cluster',
    wait_ready: true, // Wait for process.send('ready')
    listen_timeout: 10000,
    kill_timeout: 5000,
    max_memory_restart: '500M',
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
}

Graceful Shutdown in Your Node.js Application

// server.js
const express = require('express');
const app = express();

app.get('/health', (req, res) => res.json({ status: 'ok' }));

const server = app.listen(3000, () => {
  // Signal PM2 that the app is ready
  if (process.send) process.send('ready');
});

// Graceful shutdown
process.on('SIGINT', () => {
  console.log('Graceful shutdown initiated...');
  server.close(() => {
    // Close database connections
    db.end().then(() => {
      console.log('All connections closed');
      process.exit(0);
    });
  });
});

With this configuration, running pm2 reload myapp will restart workers one at a time. Each new worker must send the ready signal before PM2 stops the next old worker, ensuring zero dropped requests.

Database Migrations During Zero-Downtime Deployments

Database schema changes are the trickiest part of zero-downtime deployment. During a rolling update, both old and new versions of your code may be running simultaneously. Your migrations must be backward-compatible.

The Golden Rule of Zero-Downtime Migrations: Never remove or rename a column in the same deployment that changes the code. Always use a two-phase approach: first deploy code that works with both old and new schema, then deploy the migration, then deploy code that only uses the new schema.

Safe vs Unsafe Migration Patterns

OperationSafe ApproachUnsafe Approach
Add ColumnAdd with default, nullableAdd NOT NULL without default
Remove ColumnStop reading → deploy → dropDrop column directly
Rename ColumnAdd new → copy → drop old (3 deploys)ALTER RENAME
Add IndexCREATE INDEX CONCURRENTLYCREATE INDEX (locks table)
Change TypeAdd new column → backfill → switchALTER COLUMN TYPE

Example: Renaming a Column Safely

-- Deploy 1: Add new column, backfill, code reads both
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
UPDATE users SET full_name = name WHERE full_name IS NULL;

-- Deploy 2: Code only writes to full_name, reads full_name
-- (old code still reading 'name' works fine)

-- Deploy 3: Drop old column
ALTER TABLE users DROP COLUMN name;

Health Checks and Load Balancer Draining

Health checks are the backbone of zero-downtime deployment. Without proper health checks, your load balancer might route traffic to a container that has not finished starting up, or continue sending requests to a shutting-down instance.

Three Types of Health Checks

Liveness Check

Is the process alive? Returns 200 if the application process is running. Used to detect crashed processes.

GET /healthz → 200 OK

Readiness Check

Is the application ready to serve traffic? Checks database connection, cache availability, and warm-up status.

GET /ready → 200 OK or 503

Implementing Connection Draining

When shutting down an instance, you want to stop accepting new connections while allowing existing requests to complete. This is called connection draining or graceful shutdown.

# Nginx upstream with health checks
upstream backend {
  server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
  server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
  server 10.0.0.3:3000 max_fails=3 fail_timeout=30s;
}

Rollback Procedures

Every zero-downtime deployment strategy should include a fast rollback mechanism. The best deployments are the ones you can undo in seconds, not minutes.

StrategyRollback MethodRollback Time
Blue-GreenSwitch load balancer back< 5 seconds
SymlinkPoint symlink to previous release< 2 seconds
Docker Rollingdocker service rollback30-60 seconds
PM2 Clusterpm2 deploy revert10-30 seconds

Symlink Rollback Script

#!/bin/bash
# rollback.sh - Instant rollback to previous release
APP_DIR="/var/www/myapp"
CURRENT=$(readlink "$APP_DIR/current")
PREVIOUS=$(ls -dt "$APP_DIR/releases"/*/ | sed -n '2p' | tr -d '/')

if [ -z "$PREVIOUS" ]; then
  echo "No previous release found!"
  exit 1
fi

ln -sfn "$PREVIOUS" "$APP_DIR/current_tmp"
mv -Tf "$APP_DIR/current_tmp" "$APP_DIR/current"
systemctl reload php8.3-fpm

echo "Rolled back from $(basename $CURRENT) to $(basename $PREVIOUS)"

Atomic Deployments with Rsync

When deploying to remote servers, rsync is your best friend. It only transfers changed files, uses compression, and can be combined with the symlink strategy for atomic deployments.

# Efficient rsync with hard links to previous release
$ rsync -az --delete \
  --link-dest=/var/www/myapp/releases/previous/ \
  ./dist/ server:/var/www/myapp/releases/new/

# --link-dest creates hard links for unchanged files
# Result: each release uses minimal extra disk space
# A 500MB app with 10 releases might only use 600MB total
The --link-dest trick: This option tells rsync to create hard links to the reference directory for unchanged files. Each release directory appears to contain the full application, but unchanged files share the same disk blocks. This lets you keep many releases for fast rollback without wasting disk space.

Comparing Deployment Strategies

FeatureBlue-GreenSymlinkDocker RollingPM2 Cluster
ComplexityMediumLowMediumLow
Resource Usage2x servers1x + disk1.3x during update1x
Rollback SpeedInstantInstant30-60s10-30s
Best ForCritical appsPHP appsMicroservicesNode.js apps
DB MigrationNeeds careNeeds careNeeds careNeeds care

Putting It All Together: A Complete Workflow

Here is a production-ready deployment workflow that combines multiple strategies:

Git Push
CI Tests
Build Artifact
Upload to Server
Health Check
Swap Traffic
  • Code is pushed to the main branch
  • CI pipeline runs unit tests, integration tests, and linting
  • Build artifacts are created (compiled assets, bundled code)
  • Artifacts are rsynced to a new release directory on the server
  • Dependencies are installed and migrations run on the new release
  • Health checks verify the new version is working
  • Symlink or load balancer is switched atomically
  • Old release is kept for instant rollback
  • Monitoring confirms no errors in the new version

Final Thoughts

Zero-downtime deployment is not a luxury reserved for large teams or complex architectures. Even a single-server PHP application can achieve it with a simple symlink-based deployment script. The key principles are the same across all strategies: prepare the new version in isolation, verify it works, switch traffic atomically, and keep the old version ready for rollback.

Start with the simplest strategy that fits your stack. For PHP applications, symlink switching is the clear winner. For Node.js, PM2 cluster mode provides graceful restarts out of the box. For Docker-based applications, rolling updates are built into the platform. And for mission-critical systems where rollback speed matters most, blue-green deployment offers the strongest guarantees.

Whatever strategy you choose, practice it. Run deployments frequently — daily if possible. The more you deploy, the more confidence you build in your process, and the smaller each deployment becomes. Small, frequent deployments are inherently less risky than large, infrequent ones. Make deployment boring, and your users will never notice it happening.

Security-first hosting panel

Stop bolting tools onto a legacy panel.

Panelica is a modern, security-first hosting panel — isolated services, built-in Docker and AI-assisted management, with one-click migration from any panel.

Zero-downtime migration Fully isolated services Cancel anytime
Share:
Built in Go.