Linux Basics

Zero-downtime PHP deployments: release directories, an atomic symlink switch and OPcache

Deploy PHP apps without downtime: build each release in its own directory, switch a symlink atomically, use $realpath_root, reload PHP-FPM and roll back fast.

Published
Reading time
7 min read

Copying new files over a live PHP application is how sites end up serving half-old, half-new code for a few seconds, or a white page when a deploy fails halfway. The common fix is simple: build every release in its own directory, switch a current symlink to it in one atomic step, and make sure Nginx, PHP-FPM and OPcache all notice the switch. This guide shows that pattern on a Debian or Ubuntu server with Nginx and PHP-FPM, plus the rules for database migrations and rollbacks.

The layout

/var/www/myapp/
  releases/
    20261030120000/
    20261031090000/
  shared/
    .env
    storage/
  current -> releases/20261031090000

Each release is a complete, read-only copy of the application. Anything that must survive deploys, such as the .env file, uploaded files, sessions and logs, lives in shared/ and is linked into each release. Nginx always points at current.

Build the new release next to the live one

All the slow and risky work happens before users see anything. A minimal deploy script:

#!/usr/bin/env bash
set -euo pipefail
APP=/var/www/myapp
REL=$APP/releases/$(date +%Y%m%d%H%M%S)

git clone --depth 1 --branch "$1" https://git.example.com/myapp.git "$REL"
cd "$REL"
composer install --no-dev --optimize-autoloader --no-interaction
ln -s $APP/shared/.env "$REL/.env"
rm -rf "$REL/storage" && ln -s $APP/shared/storage "$REL/storage"

# run backward-compatible database migrations here

ln -s "$REL" $APP/current.new
mv -T $APP/current.new $APP/current
sudo systemctl reload php8.4-fpm

# keep the five newest releases
ls -1dt $APP/releases/* | tail -n +6 | xargs -r rm -rf

composer install --no-dev skips development packages, and --optimize-autoloader converts PSR-4 autoloading to a class map for faster loading. Because of set -e, any failure before the switch leaves the live site untouched; you only end up with an unused directory in releases/.

Switch the symlink atomically

The two lines that matter are:

ln -s "$REL" $APP/current.new
mv -T $APP/current.new $APP/current

The new link is created under a temporary name, then renamed over current. GNU mv moves a file by simply renaming it when it can, and the Linux rename(2) documentation states that an existing destination is replaced atomically, so there is no moment at which another process finds current missing. The -T option (--no-target-directory) is essential: without it, mv sees that current resolves to a directory and moves the new link into the old release instead of replacing it.

Make Nginx and PHP see the new release

If Nginx passes the symlinked path to PHP, two caches can keep the old release alive after the switch. PHP's realpath cache stores resolved paths for realpath_cache_ttl seconds (120 by default), and OPcache caches compiled scripts by file path. The fix is to resolve the symlink in Nginx on every request with $realpath_root, which the Nginx documentation defines as the root path with all symbolic links resolved. Symfony's web server documentation recommends exactly this for symlinked deploys, noting that otherwise OPcache may not detect changes:

server {
    listen 80;
    server_name example.com;
    root /var/www/myapp/current/public;
    index index.php;

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

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
    }
}

Check the socket path against the listen line in /etc/php/8.4/fpm/pool.d/www.conf (Debian 13 ships PHP 8.4, Debian 12 ships PHP 8.2). Symfony adds one caveat: if PHP-FPM runs on a different machine from Nginx, $realpath_root may not resolve as you expect.

OPcache settings and the FPM reload

In production you can stop OPcache from checking file timestamps on every request with opcache.validate_timestamps=0. The PHP manual is explicit about the consequence: with this setting disabled, you must reset OPcache manually with opcache_reset(), opcache_invalidate() or by restarting the web server before file changes take effect. With timestamps enabled (the default), changes are picked up only every opcache.revalidate_freq seconds (2 by default), so even then a release can be served from stale cache briefly.

The release-directory pattern sidesteps most of this, because a new release has new real paths, so OPcache compiles its files as new scripts. The deploy script then runs systemctl reload php8.4-fpm. On Debian the reload sends SIGUSR2, which the php-fpm manual describes as a graceful reload of all workers plus the FPM configuration and binary: running requests finish and new workers take over. Confirm the result on your own server, for example with a version string the application prints, and avoid a full restart during traffic.

Database migrations: expand, then contract

The symlink switch is instant, but the database is shared by the old and new code for a short time, and by the old code again if you roll back. Write migrations so both versions work against the schema:

  • Expand before the switch: add new tables, nullable columns or new indexes. The old code ignores them.
  • Switch the code.
  • Contract in a later release: drop or rename old columns only once no running release uses them.

A migration that renames a column in the same deploy that changes the code will break whichever version is not expecting it.

Rollback

Because old releases stay on disk, rolling back is the same atomic switch in reverse:

ln -s /var/www/myapp/releases/20261030120000 /var/www/myapp/current.new
mv -T /var/www/myapp/current.new /var/www/myapp/current
sudo systemctl reload php8.4-fpm

This only works if the previous release still runs against the current schema, which is the point of the expand-and-contract rule.

Pitfalls

  • Writing into the release directory. Uploads saved inside a release disappear on the next deploy. Point every writable path to shared/.
  • Long-running workers. Queue workers, schedulers and daemons started from the old release keep running old code until you restart them. Restart them as part of the deploy.
  • Caches built at runtime. Framework caches that store absolute paths should be built inside the new release before the switch, not copied from the old one.
  • Disk space. Keep a fixed number of releases and delete the rest, as the script does.

What you should do

  1. Move to a releases/, shared/ and current layout, with all writable data in shared/.
  2. Build, install dependencies and run checks in the new release directory before switching.
  3. Switch with ln -s plus mv -T, never by copying files over the live tree.
  4. Use $realpath_root for SCRIPT_FILENAME and DOCUMENT_ROOT, and reload PHP-FPM after each switch.
  5. Keep migrations backward compatible and practise a rollback before you need one.

Sources

SKYLINE Engineering

@skyline

The engineering team at SKYLINE Industrial Solutions. We publish field-tested guides drawn from real KSA and GCC deployments.

See author profile
SKYLINE engineering services

Need this implemented for you?

Reading is free — building it right takes a team. SKYLINE engineers ship Linux Basics for Aramco vendors, banks, hospitals and government agencies across Saudi Arabia. Talk to us before you start.

Aramco Approved Contractor ISO 9001 · ISO 27001 SAMA CSF aligned NCA ECC ready 247+ KSA clients

Comments

0 total · 0 threads
Be the first to leave a comment.