Tutorials

Zero-Downtime PHP: Bulletproof Deployments, Safe Migrations, and Instant Rollbacks

Zero-Downtime PHP: Bulletproof Deployments, Safe Migrations, and Instant Rollbacks

A deployment is not zero-downtime merely because the process manager stayed alive. The real test is harsher: requests arriving during the release must receive one coherent version of the application, the database must remain usable by both old and new code, and a failed health check must restore the previous release immediately.

This tutorial builds that system for PHP 8.3, PHP-FPM, Nginx, and PostgreSQL. Releases are immutable directories. An atomic symbolic-link change activates new code, expand-and-contract migrations preserve compatibility, and separate liveness and readiness checks catch different classes of failure.

Prerequisites and operating model

The example uses a current Linux distribution with PHP 8.3, the PDO PostgreSQL extension, PHP-FPM, Nginx, PostgreSQL, GNU coreutils, curl, tar, and flock. Administrative installation and Nginx changes require root privileges. Routine deployments should run as a dedicated, unprivileged deploy account.

The application lives under /srv/acme:

/srv/acme/
├── current -> /srv/acme/releases/20260811T143000Z-a1b2c3d
├── releases/
│   └── 20260811T143000Z-a1b2c3d/
│       ├── public/index.php
│       ├── migrations/002_add_display_name.sql
│       └── vendor/
└── deploy.lock

/etc/acme/
├── app.env
└── deploy.env

/usr/local/bin/
└── deploy-acme

CI must build the artifact, install production dependencies with a locked dependency file, run tests, and package the application. Production never modifies a release after extraction. Shared writable data belongs outside release directories or, preferably, in object storage.

This design assumes one host, but the release protocol also works behind a load balancer. On multiple hosts, drain and update instances gradually, while keeping every database change compatible with the entire mixed-version fleet.

Build a small production application

Create public/index.php in the build workspace. It exposes a database-independent liveness endpoint, a database-dependent readiness endpoint, and an example query that tolerates the new nullable column introduced later.

<?php
declare(strict_types=1);

function respond(int $status, array $body): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    header('Cache-Control: no-store');
    echo json_encode($body, JSON_THROW_ON_ERROR);
    exit;
}

function database(): PDO
{
    $config = parse_ini_file('/etc/acme/app.env', false, INI_SCANNER_RAW);
    if ($config === false) {
        throw new RuntimeException('Application configuration is unavailable');
    }

    $pdo = new PDO(
        $config['DATABASE_DSN'],
        $config['DATABASE_USER'],
        $config['DATABASE_PASSWORD'],
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]
    );

    $pdo->exec("SET statement_timeout = '2000ms'");
    $pdo->exec("SET lock_timeout = '500ms'");
    $pdo->exec("SET idle_in_transaction_session_timeout = '3000ms'");

    return $pdo;
}

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);

if ($path === '/health/live') {
    respond(200, ['status' => 'alive']);
}

if ($path === '/health/ready') {
    try {
        database()->query('SELECT 1')->fetchColumn();
        respond(200, ['status' => 'ready']);
    } catch (Throwable) {
        respond(503, ['status' => 'not_ready']);
    }
}

if ($path === '/users') {
    try {
        $rows = database()->query(
            "SELECT id, email, COALESCE(display_name, email) AS display_name
             FROM users
             ORDER BY id
             LIMIT 100"
        )->fetchAll();

        respond(200, ['users' => $rows]);
    } catch (Throwable $exception) {
        error_log(sprintf(
            'request_failed type=%s path=%s',
            $exception::class,
            $path
        ));
        respond(500, ['error' => 'internal_error']);
    }
}

respond(404, ['error' => 'not_found']);

The PostgreSQL DSN should include a bounded connection timeout, for example pgsql:host=127.0.0.1;port=5432;dbname=acme;connect_timeout=2;application_name=acme_web. That timeout covers connection establishment, not queries. The session-level statement_timeout and lock_timeout independently bound query execution and lock waits.

Store credentials in /etc/acme/app.env, owned by root:www-data with mode 0640. Do not place secrets in artifacts, environment dumps, health responses, or deployment logs.

Make Nginx resolve one immutable release per request

Configure the virtual host with the stable current link as its root. Crucially, pass $realpath_root to PHP-FPM. Nginx resolves the symbolic link to the active release, so PHP receives a release-specific script path rather than an ambiguous path through current.

server {
    listen 80;
    server_name app.example.com;

    root /srv/acme/current/public;
    index index.php;

    location / {
        try_files $uri /index.php?$query_string;
    }

    location = /index.php {
        internal;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_connect_timeout 2s;
        fastcgi_send_timeout 10s;
        fastcgi_read_timeout 10s;
    }

    location ~ ^/health/(live|ready)$ {
        allow 127.0.0.1;
        allow ::1;
        deny all;

        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_connect_timeout 2s;
        fastcgi_send_timeout 5s;
        fastcgi_read_timeout 5s;
    }

    location ~ \.php$ {
        return 404;
    }
}

Validate with nginx -t before reloading Nginx. Restricting health endpoints to loopback prevents them from becoming public diagnostics. If an external load balancer needs access, permit only its explicit network and enforce the same restriction in the host firewall. Public traffic should terminate TLS at Nginx or a trusted upstream proxy.

PHP opcache can remain enabled. Distinct real paths give each release distinct cache keys, avoiding stale bytecode associated with a reused pathname. Existing PHP requests continue executing code already loaded from the old release, while new requests enter the new one.

Use expand-and-contract database migrations

Suppose the new release adds users.display_name. Adding a required column, rewriting every row, and deploying code that immediately depends on it would create both lock risk and an unsafe rollback boundary.

The expansion migration adds only a nullable column and applies short database timeouts:

BEGIN;

SET LOCAL lock_timeout = '500ms';
SET LOCAL statement_timeout = '5s';

ALTER TABLE users
    ADD COLUMN IF NOT EXISTS display_name text;

COMMIT;

If the table cannot be locked within 500 milliseconds, the migration fails instead of waiting behind production traffic. The deployment stops before activation. PostgreSQL transactional DDL also prevents a partially applied change in this example.

The new application reads COALESCE(display_name, email), while old code ignores the column. New writers should populate both the old representation and display_name. Backfill existing rows separately in small transactions:

WITH batch AS (
    SELECT id
    FROM users
    WHERE display_name IS NULL
    ORDER BY id
    LIMIT 500
    FOR UPDATE SKIP LOCKED
)
UPDATE users AS u
SET display_name = split_part(u.email, '@', 1)
FROM batch
WHERE u.id = batch.id
RETURNING u.id;

Repeat until no rows are returned, pausing between batches when replication lag, lock waits, or database latency rises. Each batch is independently committed; do not wrap the entire backfill in one transaction.

The contract phase comes in a later release, after all application versions write the column and verification shows no nulls. Only then may code remove the fallback or a migration add a constraint. Database rollback should rarely mean reversing schema immediately: rolling application code back is safer while the expanded schema remains backward compatible.

Implement the atomic deployment

Place the following root-owned, non-writable script at /usr/local/bin/deploy-acme. Grant the deployment account access only to the required release directory and deployment credential file. The script accepts an artifact and a release identifier containing only letters, digits, dots, underscores, and hyphens.

#!/usr/bin/env bash
set -Eeuo pipefail
umask 027

artifact=${1:?usage: deploy-acme ARTIFACT RELEASE_ID}
release_id=${2:?usage: deploy-acme ARTIFACT RELEASE_ID}

if [[ ! $release_id =~ ^[A-Za-z0-9._-]+$ ]]; then
    echo "invalid release identifier" >&2
    exit 64
fi

base=/srv/acme
release="$base/releases/$release_id"
current="$base/current"
candidate_link="$base/.current-$release_id"

exec 9>"$base/deploy.lock"
flock -n 9 || {
    echo "another deployment is running" >&2
    exit 75
}

[[ -f $artifact ]] || {
    echo "artifact not found" >&2
    exit 66
}

[[ ! -e $release ]] || {
    echo "release already exists" >&2
    exit 73
}

mkdir -m 0750 "$release"
tar --extract --gzip \
    --file "$artifact" \
    --directory "$release" \
    --no-same-owner \
    --no-same-permissions

[[ -f "$release/public/index.php" ]] || {
    echo "invalid artifact" >&2
    exit 65
}

php -d display_errors=0 -l "$release/public/index.php"

set -a
. /etc/acme/deploy.env
set +a

for migration in "$release"/migrations/*.sql; do
    [[ -e $migration ]] || continue
    psql "$DATABASE_URL" \
        --set ON_ERROR_STOP=1 \
        --file "$migration"
done

php -S 127.0.0.1:9081 -t "$release/public" \
    >"/tmp/acme-preflight-$release_id.log" 2>&1 &
preflight_pid=$!

cleanup() {
    kill "$preflight_pid" 2>/dev/null || true
    wait "$preflight_pid" 2>/dev/null || true
}
trap cleanup EXIT

for attempt in 1 2 3 4 5; do
    if curl --fail --silent --show-error \
        --connect-timeout 1 --max-time 3 \
        http://127.0.0.1:9081/health/ready >/dev/null; then
        break
    fi
    [[ $attempt -lt 5 ]] || exit 1
    sleep 1
done

previous=
if [[ -L $current ]]; then
    previous=$(readlink -f "$current")
fi

ln -s "$release" "$candidate_link"
mv -Tf "$candidate_link" "$current"

if ! curl --fail --silent --show-error \
    --connect-timeout 1 --max-time 5 \
    --header 'Host: app.example.com' \
    http://127.0.0.1/health/ready >/dev/null; then
    if [[ -n $previous && -d $previous ]]; then
        ln -s "$previous" "$candidate_link"
        mv -Tf "$candidate_link" "$current"
    fi
    echo "activation failed; previous release restored" >&2
    exit 1
fi

echo "activated $release_id"

The temporary link and current reside on the same filesystem, allowing GNU mv -T to replace the link atomically. Never point current at a directory being populated. The lock prevents two deployments from racing.

The temporary PHP server checks the candidate’s bootstrap and database access before activation. The post-switch request then verifies the real Nginx and PHP-FPM path. A failed post-switch check restores the prior link. Retain several known-good releases so an operator can perform the same atomic link replacement manually if automation itself fails.

Test failure paths, not only the happy path

Exercise the complete protocol in staging with production-like PHP-FPM and database settings:

  • Send continuous requests during activation and confirm every response is successful and belongs wholly to either release.
  • Use an invalid database password for the candidate and verify preflight prevents the link change.
  • Hold a conflicting table lock and confirm the migration exits after its lock timeout.
  • Make the post-activation readiness check fail and verify current returns to the previous target.
  • Run two deployments simultaneously and confirm one exits without modifying releases.
  • Terminate a long-running old request after activation and confirm the release remains present until that request finishes.

Do not delete the previous release immediately. PHP-FPM workers may still be serving requests whose entry script came from it. Retention cleanup should remove only older, inactive releases after a conservative request-duration window.

Observe the release as a production event

Record the release identifier in deployment logs and expose it in structured application logs or a harmless response header. Track readiness failures, HTTP error rate, latency, PHP-FPM queue depth, worker saturation, database lock waits, statement timeouts, connection exhaustion, and replication lag.

Liveness should answer whether the PHP process can execute a request. Readiness should answer whether the instance can serve useful traffic. Do not make liveness depend on PostgreSQL: a database outage should remove traffic through readiness, not trigger a storm of PHP-FPM restarts.

Performance tuning still matters. Size PHP-FPM’s worker pool against memory and database connection capacity, not CPU count alone. Keep opcache warm through a controlled canary request set if cold-start latency is significant. Avoid broad readiness queries; SELECT 1 proves connectivity without turning health checks into workload.

Final verification checklist

  • The artifact is immutable, tested, and identified by a unique release ID.
  • Nginx passes a release-specific real path to PHP-FPM.
  • Activation is an atomic same-filesystem symbolic-link replacement.
  • Connection, query, lock, FastCGI, curl, and deployment waits are independently bounded.
  • Migrations are additive and compatible with both old and new code.
  • Backfills run in small, observable transactions outside activation.
  • Candidate and post-switch readiness checks both pass.
  • A failed activation restores the previous application release automatically.
  • Health endpoints and secrets are access-controlled.
  • Old releases remain available until in-flight requests cannot reference them.

Reliable deployment is less about a clever command than about preserving compatibility at every boundary. Immutable code makes activation atomic. Expand-and-contract migrations make rollback possible. Bounded health checks turn uncertainty into a clear decision. When those pieces reinforce one another, deployment stops being a hopeful restart and becomes a routine, reversible production operation.

Blog author portrait

Mihajlo

I’m Mihajlo — a developer driven by curiosity, discipline, and the constant urge to create something meaningful. I share insights, tutorials, and free services to help others simplify their work and grow in the ever-evolving world of software and AI.