How to deploy a Laravel app with no downtime using release folders
Build each new version in its own folder while the old one keeps serving visitors. Then switch in one step. If something is wrong, switch back just as fast.
.env settings, file permissions, the scheduler and queue workers. This guide builds on it and doesn't repeat it.Before you start
- Take a backup of the files and the database. See our backup guide.
- The examples use Ubuntu 24.04 with PHP 8.3. On Debian 13 the version is 8.4, so change
8.3to8.4everywhere. The app is calledshop, runs as its own usershop(see one PHP-FPM pool per site) and is served by Nginx. - You don't need a special tool. Deployer (free, open source) and Envoyer (a paid Laravel service) do all of this for you. The small script below does the same basic steps, so you can see exactly what happens.
1. How release folders work
Today your site probably lives in one folder, and a deploy changes files in it while visitors use it. For a few seconds, some files are new and some are old, and visitors can get errors.
With release folders, every deploy gets a fresh folder. A link called current points at the live one:
/srv/apps/shop/
├── releases/
│ ├── 20261110093000/
│ ├── 20261111141500/
│ └── 20261112090000/ ← the newest
├── shared/
│ ├── .env
│ └── storage/
└── current -> releases/20261112090000
- The new release is built next to the live one. Visitors don't see it until it's ready.
shared/holds what must stay the same between releases: your.envsettings, uploaded files, logs and sessions instorage/.- Going live means pointing
currentat the new folder. Going back means pointing it at the old one.
2. Create the folders and move the shared files
Make the folders, and copy .env and storage from your current site. Here the old site is in /var/www/shop:
sudo mkdir -p /srv/apps/shop/releases /srv/apps/shop/shared
sudo cp -a /var/www/shop/.env /srv/apps/shop/shared/.env
sudo cp -a /var/www/shop/storage /srv/apps/shop/shared/storage
sudo chown -R shop:shop /srv/apps/shop
sudo chmod 640 /srv/apps/shop/shared/.env
Using SQLite? The database file is in database/ inside the project by default, so each release would get its own empty copy. Move it to shared/ and give the full path in .env:
DB_CONNECTION=sqlite
DB_DATABASE=/srv/apps/shop/shared/database.sqlite
The PHP-FPM pool runs as shop in our examples. If yours runs as www-data, give it write access to shared/storage as shown in step 4 of the Laravel deploy guide.
3. Point Nginx at the current link
In the site's file in /etc/nginx/sites-available/, set the root to current/public, and pass PHP the real folder, not the link. These lines come from the Laravel deployment docs, plus one more:
root /srv/apps/shop/current/public;
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/shop.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_hide_header X-Powered-By;
}
$realpath_root is the root folder with the link already followed, for example /srv/apps/shop/releases/20261112090000/public. So each new release reaches PHP under a new path, and PHP loads it fresh.
Why this matters: PHP remembers where files are, and keeps compiled code in memory (OPcache). With the usual $document_root instead, PHP always sees the same path, current/public/index.php. In our test it kept serving the old release after the switch: all 2,720 requests in the next 2 minutes got the old code, and it only changed when we reloaded PHP-FPM.
Moving an existing site? Make this change after the first deploy (step 7), because current doesn't exist yet.
Laravel only needs the SCRIPT_FILENAME line. The DOCUMENT_ROOT line helps other code that reads it. Check and reload Nginx:
sudo nginx -t && sudo systemctl reload nginx
4. Let PHP-FPM finish requests when it reloads
The deploy reloads PHP-FPM to clear its memory of old releases. By default, a reload stops PHP workers right away, even in the middle of a request. In our test, that gave visitors a "502 Bad Gateway" error 4 or 5 times per deploy.
Tell PHP-FPM to wait for running requests first. Open /etc/php/8.3/fpm/php-fpm.conf and find this line near the top, in the [global] part:
;process_control_timeout = 0
Change it to:
process_control_timeout = 10s
Make it longer if some of your pages take more than 10 seconds. Don't add the line at the end of the file. In our test PHP-FPM then refused to start, because the end of the file belongs to a pool, not to [global]. Check and restart:
sudo php-fpm8.3 -t
sudo systemctl restart php8.3-fpm
After this change: no errors at all in our tests.
5. Allow the site user to reload PHP-FPM
The deploy runs as shop, not root. Give it permission for this one command and nothing else:
echo 'shop ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.3-fpm' | sudo tee /etc/sudoers.d/shop-deploy
sudo chmod 440 /etc/sudoers.d/shop-deploy
sudo visudo -c
The last command must say the files are "parsed OK".
6. The deploy script
Save this as /usr/local/bin/deploy-shop and make it runnable with sudo chmod 755 /usr/local/bin/deploy-shop. Change the four settings at the top:
#!/bin/bash
# Deploy shop into a new release folder, then switch to it.
set -euo pipefail
APP=/srv/apps/shop
REPO=git@github.com:you/shop.git
BRANCH=main
PHP_FPM=php8.3-fpm
KEEP=5
RELEASE=$APP/releases/$(date +%Y%m%d%H%M%S)
# 1. Get the code
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO" "$RELEASE"
cd "$RELEASE"
echo "Deploying $(git rev-parse --short HEAD) to $RELEASE"
# 2. Use the shared .env and storage
rm -rf storage
ln -s "$APP/shared/storage" storage
ln -s "$APP/shared/.env" .env
# 3. Install packages (stop if one has a known security hole)
composer audit --locked --no-dev
composer install --no-dev --optimize-autoloader --no-interaction
# 4. Build CSS and JavaScript, if your app needs it
# npm ci && npm run build
# 5. Update the database
php artisan migrate --force
# 6. Cache settings, routes and views
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 7. Switch to the new release in one step
ln -sfn "$RELEASE" "$APP/current-new"
mv -T "$APP/current-new" "$APP/current"
# 8. Clear PHP's caches and restart the queue workers
sudo systemctl reload "$PHP_FPM"
php artisan queue:restart
# 9. Keep the newest releases, delete the rest
cd "$APP/releases"
ls -1 | sort | head -n -"$KEEP" | while read -r old; do
[ "$APP/releases/$old" = "$(readlink -f "$APP/current")" ] || rm -rf "$old"
done
echo "Done: $RELEASE is live"
set -euo pipefail stops the script at the first error. Everything before step 7 happens in the new folder, so if anything fails, the live site doesn't change. Here is what each part does.
Getting the code
The server needs read access to your repository. Add a deploy key on GitHub (or an access key on Bitbucket) with write access turned off. --depth 1 downloads only the newest version, which is faster. If the server can't reach your Git host, make a file on your own computer with git archive --format=tar.gz -o shop.tar.gz main, upload it, and unpack it into the new release folder instead.
Shared files
The release gets links to the shared .env and storage. Uploaded files, logs and sessions stay in one place, and nothing is lost when old releases are deleted.
Packages
composer audit checks your locked packages for known security holes, and stops the deploy if it finds one. composer install then installs exactly what's in composer.lock, without development tools. Our guide on keeping Composer packages safe and up to date explains both.
Assets
If you build CSS and JavaScript with Vite, remove the # in step 4. This needs Node.js on the server. You can also build them before you push and commit the public/build folder.
Database changes
Migrations run before the switch, so for a moment the old code runs with the new database. That's fine as long as each change still works with the old code:
- Safe: adding a new table, or a new column that can be empty or has a default.
- Not safe in one deploy: renaming or deleting a column the old code still uses. Do it in two deploys. First, deploy code that no longer uses the column. Then, in a later deploy, remove or rename it.
Big tables can lock while they change. Test large migrations on a copy first.
Caches
config:cache, route:cache and view:cache make the app faster. Each release gets its own cached settings, built when it's deployed. One catch: once settings are cached, Laravel doesn't read .env again. If you change shared/.env later, run php artisan config:cache in current (or deploy again). Our email guide shows what happens otherwise: in our test, a new mail password was simply ignored.
The switch
Some versions of ln -sfn remove the old link first and then make the new one. For a tiny moment there is no current at all. So the script makes a new link with another name, then renames it over current with mv -T. A rename always happens in one step, so every request sees either the old release or the new one, never nothing.
PHP-FPM and queue workers
With $realpath_root, new requests use the new release right away. The reload clears out the old code PHP still keeps in memory. It's also needed if you turned off opcache.validate_timestamps, as our OPcache guide explains.
Queue workers keep old code in memory too. queue:restart tells each worker to finish its current job and exit, and systemd starts it again in the new release. Point the workers at current, not at a release folder. In the systemd file from our queue workers guide:
WorkingDirectory=/srv/apps/shop/current
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600 --memory=256
Do the same for the scheduler's cron line:
* * * * * cd /srv/apps/shop/current && php artisan schedule:run >> /dev/null 2>&1
Old releases
The last part keeps the 5 newest releases and deletes older ones. It never deletes the live one. Keeping a few gives you something to go back to.
7. Deploy
Run the script as the site user:
sudo -u shop -H deploy-shop
On the very first run, current doesn't exist yet. The script creates it. Then point Nginx at it (step 3), and update the queue workers and cron (step 6). Check what's live:
ls -l /srv/apps/shop/current
curl -I https://example.com
When everything works, you can remove the old /var/www/shop folder.
8. Roll back
Something wrong with the new release? List the releases and point current at the one before:
ls /srv/apps/shop/releases
sudo -u shop ln -sfn /srv/apps/shop/releases/20261111141500 /srv/apps/shop/current-new
sudo -u shop mv -T /srv/apps/shop/current-new /srv/apps/shop/current
sudo systemctl reload php8.3-fpm
cd /srv/apps/shop/current && sudo -u shop php artisan queue:restart
This takes a few seconds. It doesn't undo database changes. If you followed the rules for safe migrations, the old code works fine with the newer database. If you must undo a migration, run php artisan migrate:rollback in the new release before you switch back, and only when you know what it removes.
Then fix the problem in Git and deploy again. The next deploy makes a new folder, so the broken release is cleaned up later.
If something goes wrong
- The deploy stopped with an error: the live site didn't change. The half-built release folder stays in
releases/. Read the error, delete that folder, fix the problem and deploy again. In our test, a broken migration stopped the deploy and the site kept running the old release. - The site still shows the old version after a deploy: Nginx uses
$document_rootinstead of$realpath_root, or the reload didn't run. Check step 3, then runsudo systemctl reload php8.3-fpm. - "502 Bad Gateway" for a moment during deploys: PHP-FPM stops running requests when it reloads. Set
process_control_timeout(step 4). - PHP-FPM won't start after you changed php-fpm.conf: the setting is in the wrong place. Put it in the
[global]part, near the top, and runsudo php-fpm8.3 -t. - sudo asks for a password during the deploy: the sudo rule from step 5 is missing, or the PHP version in it is wrong.
- Queue jobs still run old code: the workers point at a release folder instead of
current, orqueue:restartdidn't run. It needs a real cache store, such asdatabaseorredis. - "composer audit" stops the deploy: a package has a known security hole. Update it on your own computer, test, commit
composer.lockand deploy again. - Settings changes in .env are ignored: run
php artisan config:cachein/srv/apps/shop/current.
What we tested, and what we couldn't
We tested every step on Ubuntu 24.04 (PHP 8.3, MariaDB) and Debian 13 (PHP 8.4, SQLite), with a real Laravel 13 app deployed from a Git repository, two queue workers run by systemd, and the script above. During each deploy, we sent about 20 requests a second to the site, plus a slow page that takes 2 seconds, and counted the errors:
- With the setup in this guide: 3 deploys on each system, with about 1,280 requests during each set, 0 errors and no request served by an old release after the switch.
- Without
process_control_timeout: 14 errors ("502 Bad Gateway") in 3 deploys on each system, mostly on the slow page. - With
$document_rootand no reload: every request kept getting the old release, on both systems, for as long as we watched. On Ubuntu that was more than 4 minutes. A reload fixed it at once. With$document_rootand the reload, the new release was served right away. - With
$realpath_rootand no reload: the new release was served right away, even withopcache.validate_timestampsturned off. We still recommend the reload, to clear the old code from memory. - Queue workers ran jobs with the new code after each deploy. When a deploy stopped before
queue:restart, they kept running old code. - Rolling back under the same traffic gave 0 errors, and the old release was served right away, with the newer database.
- A failing migration stopped the deploy, and the site stayed on the old release.
- Keeping 5 releases deleted the older ones.
We didn't test building assets with npm, a real GitHub deploy key (we used a local Git repository), or the scheduler's cron line with this layout.
Official sources
- Deployment (Laravel docs), including the Nginx setup
- Migrations and queue workers and deployment (Laravel docs)
- $realpath_root (nginx docs)
- PHP-FPM settings, including process_control_timeout (php.net)
- composer audit (Composer docs)
- Deploy keys (GitHub docs)
Next steps: keep your workers running with our queue workers and scheduler guide, and make sure you can restore a backup before you need it.
In the ServerNest preview, you can deploy from GitHub or Bitbucket. It keeps the last 5 releases and shared paths such as .env and storage/, and you can roll back in one click. The product is still in development. Join early access to try it first.