With a plain FTP upload or git pull, files are replaced inside the live site directory one by one. For a few seconds or minutes the site is half old and half new, and a visitor may see a blank page, a "Class not found" error or broken styling. A zero-downtime deploy avoids this: the new version is built in a separate folder, checked, and only then is the site switched to it in an instant.
Folder layout
releases holds one folder per deploy, named by timestamp. shared holds what survives across versions: .env, user uploads and logs. current is a symbolic link to the latest release, and it is the only path the web server knows about.
/var/www/example.uz/
├── current -> releases/20261104-1530
├── releases/
│ ├── 20261103-1745/
│ └── 20261104-1530/
└── shared/
├── .env
└── storage/
This is easiest on a VPS, where you set the web root yourself. On shared hosting the site folder may be fixed by the panel; ask in the sayt.uz cabinet or through support whether it can be a symlink.
Building a release
None of these steps touch the live site, so a failed Composer install simply stops the script.
#!/bin/bash
set -e
APP=/var/www/example.uz
REL=$APP/releases/$(date +%Y%m%d-%H%M%S)
git clone --depth 1 --branch main git@github.com:misol/example-sayt.git "$REL"
cd "$REL"
ln -s $APP/shared/.env .env
rm -rf storage && ln -s $APP/shared/storage storage
composer install --no-dev --optimize-autoloader
php artisan config:cache
php artisan migrate --force
Migrations run before the switch, so for a few seconds old code talks to the new schema. Write them backward compatible: add a column first and drop the old one in a later deploy.
An atomic symlink swap
ln -sfn removes the old link and then creates the new one, leaving a tiny gap. Creating a temporary link and renaming it with mv -T is a single atomic operation on Linux.
ln -s "$REL" $APP/current_tmp
mv -T $APP/current_tmp $APP/current
PHP-FPM, OPcache and Nginx
OPcache and PHP's realpath cache remember files by their resolved path, so PHP may keep running old code after the switch. Passing $realpath_root instead of $document_root from Nginx gives PHP the resolved release path on every request.
root /var/www/example.uz/current/public;
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
For extra safety run sudo systemctl reload php8.3-fpm at the end. Unlike restart, a reload lets running requests finish. Service and socket names depend on your PHP version.
Rollback and cleanup
Rolling back means pointing current at the previous folder, which takes a second. Keep a handful of releases and delete the rest. Remember that the database does not roll back with the code.
PREV=$(ls -1dt $APP/releases/* | sed -n 2p)
ln -s "$PREV" $APP/current_tmp && mv -T $APP/current_tmp $APP/current
ls -1dt $APP/releases/* | tail -n +6 | xargs -r rm -rf
Example: an online shop in Tashkent
A Tashkent appliance shop deploys several times a day and used to show an error page for a minute or two each time, losing items from shoppers' carts. After moving to this layout on a VPS, builds happen in the background and the switch is invisible. When a bug appeared on the new checkout page, the developer rolled back with one command, fixed it calmly and redeployed an hour later.
Ready-made tools
Deployer for PHP and Capistrano for Ruby create the same releases, shared and current layout automatically, handle locking and prune old releases. Combined with GitHub Actions or GitLab CI, a push to main triggers the deploy. The principle stays the same: visitors should never see a version that is not fully ready.