Zero-Downtime Deployment
Build outside the live root, move a symlink in one rename, share only env.php and media, then get PHP-FPM off the old release.
ln -sfn $NEW /var/www/current.tmp && mv -Tf /var/www/current.tmp /var/www/currentZero-downtime deployment means no request ever sees a half-written codebase. You get it by never writing into the directory the web server is serving. Each release is extracted into its own directory, and the live docroot is a symlink that is moved onto the new release only once that release is complete.
Shared and per-release
A few paths must survive the swap. app/etc/env.php holds the database credentials and the mode. pub/media holds uploaded images. var/log and var/report hold the logs you will read afterwards. Those live outside the releases and are symlinked into each one. Everything the build produced belongs to the release and must not be shared: vendor/, generated/ and pub/static differ per release, and sharing them mixes two codebases.
The swap
ln -sfn over an existing symlink unlinks it and then creates a new one, which leaves a window with no docroot at all. Create a temporary link and mv -Tf it over the old one instead. That is a single rename.
After the swap
PHP-FPM is still holding the previous release, and with opcache.validate_timestamps=0, which production wants, it will keep holding it. Either reload PHP-FPM, or have nginx pass $realpath_root so each release is a distinct path to OPcache. Then run bin/magento cache:flush from the new release.
Example
# /usr/local/bin/release.sh <artefact-sha> - run on each web node
set -e
RELEASES=/var/www/releases
SHARED=/var/www/shared
NEW="$RELEASES/$(date -u +%Y%m%dT%H%M)"
mkdir -p "$NEW/var"
tar -xzf "/artifacts/$1.tar.gz" -C "$NEW"
# shared state: one copy, outside the releases
ln -sfn "$SHARED/env.php" "$NEW/app/etc/env.php"
ln -sfn "$SHARED/media" "$NEW/pub/media"
ln -sfn "$SHARED/var/log" "$NEW/var/log"
ln -sfn "$SHARED/var/report" "$NEW/var/report"
# remember where to go back to before moving forward
ln -sfn "$(readlink -f /var/www/current)" /var/www/previous.tmp
mv -Tf /var/www/previous.tmp /var/www/previous
# one rename(2): no request sees a missing docroot
ln -sfn "$NEW" /var/www/current.tmp
mv -Tf /var/www/current.tmp /var/www/current
# PHP-FPM holds the old release until it is told otherwise
systemctl reload php8.4-fpm
/var/www/current/bin/magento cache:flush
# Flushed cache types:
# config
# layout
# block_html
# collections
# reflection
# db_ddl
# compiled_config
# eav
# config_integration
# config_integration_api
# full_page
# translateWhen to use it
- Deploy a release in the middle of the day without a maintenance page.
- Give each release its own vendor/ and generated/ so a bad build is one symlink away from gone.
- Move a node between releases to compare behaviour on identical data.
- Stop a deploy halfway with the live site untouched, because nothing was written into it.
More examples
nginx: give each release its own OPcache keys
Use this when you cannot reload PHP-FPM on every deploy, for example because reloading drops long requests. Because `$realpath_root` expands the symlink, every release arrives at PHP under a different absolute path, so OPcache treats it as new code and the old entries simply age out. The location names match Magento's shipped nginx.conf.sample, including pub/health_check.php.
# /etc/nginx/sites-enabled/shop.conf - excerpt
root /var/www/current/pub;
location ~ ^/(index|get|static|errors/report|errors/404|errors/503|health_check)\.php$ {
fastcgi_pass fastcgi_backend;
fastcgi_index index.php;
# resolve the symlink here, not in PHP: /var/www/releases/20261005T0910/pub
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
include fastcgi_params;
}Which paths are shared, in one place
Keeping the list in a function stops the two classic mistakes: adding a shared path on one node and not the other, and quietly sharing something that is per-release. var/cache and var/page_cache stay per-release because production should be using Redis for both anyway; if a node does use the filesystem cache, the cache:flush after the swap is what clears it.
# deploy/shared.sh - the list, so release.sh and a new node agree
SHARED_FILES=("app/etc/env.php")
SHARED_DIRS=("pub/media" "var/log" "var/report" "var/importexport")
link_shared() {
local release="$1" shared="$2" path
for path in "${SHARED_FILES[@]}" "${SHARED_DIRS[@]}"; do
mkdir -p "$(dirname "$release/$path")"
rm -rf "$release/$path"
ln -sfn "$shared/$path" "$release/$path"
done
}
# NOT shared, on purpose: vendor/ generated/ pub/static var/cache var/page_cache
# Those belong to one release. Sharing generated/ serves two codebases at once.Prove the new release answers before you walk away
Run this as the last step of the deploy and as the first step of deciding whether to roll back. The Host header is not optional: Magento builds URLs from the request host, so a check sent to 127.0.0.1 without it can write 127.0.0.1 into cached markup and into generated base URLs. pub/health_check.php ships with Magento and touches the database and cache, so a 200 means more than nginx being alive.
# deploy/smoke.sh - after the swap, against the local node
set -e
code=$(curl -sS -o /dev/null -w '%{http_code}' \
-H 'Host: shop.example.com' \
http://127.0.0.1/health_check.php)
if [ "$code" != "200" ]; then
echo "health_check.php returned $code - roll back" >&2
exit 1
fi
echo "ok"
# ok
Discussion