Zero-downtime Laravel deploys, without a deploy service
Build each release in its own directory, share storage and .env, swap one symlink, health-check the site and roll back on failure. A complete script.
Jacob Søndergaard ·
A zero-downtime deployment for Laravel comes down to one rule: never change the files the site is serving. Build the next release in a directory of its own, and when it is ready, move one symlink. This post builds that from scratch with a single bash script: separate release directories, shared storage and .env, an atomic swap, a PHP-FPM reload that drops no requests, and a health check that puts the previous release back when the new one answers with an error.
Every command here was run on fresh Ubuntu 26.04 and 24.04 servers with a new laravel/laravel app, with a client requesting the site in a loop through each deploy. The same server, paths and names carry through the rest of the series: queue workers and Horizon, the scheduler and the Nginx configuration.
Why git pull in place goes down
The simplest deploy is git pull && composer install && php artisan migrate inside the directory Nginx serves. For the seconds or minutes that takes, requests land on a half-updated application. Composer rewrites vendor/ and the class map while PHP is loading classes from it. New controllers arrive before the migrations they depend on. The config cache describes one version while the code is another. If any step fails, the site stays in that mixed state, and there is no previous version on disk to go back to.
Building somewhere else and switching at the end fixes all of it. The switch is the only moment the live site changes, and it takes a single system call.
The releases, shared and current layout
/var/www/example.com/
├── current -> releases/20261021113519 # what Nginx, workers and cron use
├── releases/
│ ├── 20261021113453/ # the previous release, kept for rollback
│ └── 20261021113519/
├── shared/
│ ├── .env # one environment file for every release
│ └── storage/ # logs, sessions, uploads, compiled views
└── deploy.sh- releases/ holds one directory per deploy, named after the time it started, so sorting by name sorts by age.
- shared/ holds what must survive a deploy: the environment file and storage/. Each release gets symlinks to them.
- current is a symlink to the live release. Nginx's root is current/public, and workers and cron start in current.
bootstrap/cache is not shared. It holds the cached config, routes and package manifest, which belong to one release's code. If you use SQLite, put the database file in shared/ and give DB_DATABASE its absolute path, or each release starts with an empty database.
One-time setup
The server needs Nginx, PHP-FPM, Composer and Git. Ubuntu's archive carries one PHP per release: PHP 8.5 on 26.04 and PHP 8.3 on 24.04. The commands below use 8.5. On 24.04, write 8.3 wherever they say 8.5. Deploying Laravel to Ubuntu covers installing the stack and the database in more detail.
sudo apt update
sudo apt install nginx php-fpm php-cli php-mbstring php-xml php-curl php-zip php-intl php-bcmath php-mysql composer git unzip nodejs npmNode is there for the deploy script’s npm run build; the script section below covers which Node each release ships.
A site account and its directories
The site gets a Unix account of its own. It owns the code, runs the PHP-FPM pool, the queue workers and the scheduler, and it is the only account that can read the environment file. Its home directory is the site, mode 750 with the group set to www-data, so Nginx can enter it and other accounts on the server cannot.
# The site's own account. Its home directory is the site.
sudo useradd --create-home --home-dir /var/www/example.com --shell /bin/bash example
# Nginx runs as www-data and has to enter the directory to serve files.
sudo chgrp www-data /var/www/example.com
sudo chmod 750 /var/www/example.com
sudo -u example mkdir -p /var/www/example.com/releases \
/var/www/example.com/shared/storage/{app/public,framework/{cache/data,sessions,views},logs}The shared environment file
Write the production .env once, into shared/, readable by the site account only. The heredoc generates a fresh APP_KEY. Fill in your own database details.
sudo -u example tee /var/www/example.com/shared/.env > /dev/null <<EOF
APP_NAME=Example
APP_ENV=production
APP_KEY=base64:$(openssl rand -base64 32)
APP_DEBUG=false
APP_URL=https://example.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=example
DB_USERNAME=example
DB_PASSWORD=change-me
CACHE_STORE=database
QUEUE_CONNECTION=database
SESSION_DRIVER=database
EOF
sudo chmod 600 /var/www/example.com/shared/.envA deploy key
The script clones over SSH as the site account. Give it a key of its own and add the public half to the repository as a read-only deploy key. On GitHub that is Settings, Deploy keys.
sudo -u example mkdir -m 700 /var/www/example.com/.ssh
sudo -u example ssh-keygen -t ed25519 -N '' -C example.com -f /var/www/example.com/.ssh/id_ed25519
sudo -u example sh -c 'ssh-keyscan github.com >> /var/www/example.com/.ssh/known_hosts'
sudo cat /var/www/example.com/.ssh/id_ed25519.pubA PHP-FPM pool and an Nginx server block
A pool for this site alone, running as its account, listening on its own socket:
sudo tee /etc/php/8.5/fpm/pool.d/example.com.conf > /dev/null <<'EOF'
[example.com]
user = example
group = example
listen = /run/php/example.com.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3
pm.max_requests = 500
EOF
sudo php-fpm8.5 -t
sudo systemctl reload php8.5-fpmThen the server block. Save it as /etc/nginx/sites-available/example.com. The Nginx post goes through it line by line. The line this layout depends on is $realpath_root, covered below.
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
root /var/www/example.com/current/public;
index index.php;
charset utf-8;
access_log /var/log/nginx/example.com.access.log;
error_log /var/log/nginx/example.com.error.log;
client_max_body_size 20m;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location ^~ /build/assets/ {
expires max;
access_log off;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}
error_page 404 /index.php;
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/example.com.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_hide_header X-Powered-By;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
}
location ~ /\.(?!well-known).* {
deny all;
}
}sudo nano /etc/nginx/sites-available/example.com
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
sudo nginx -t
sudo systemctl reload nginxTwo PHP-FPM settings
The deploy reloads PHP-FPM, and the site account needs permission for exactly that command and nothing else:
echo 'example ALL=(root) NOPASSWD: /usr/bin/systemctl reload php8.5-fpm' > /tmp/example.sudoers
sudo visudo -cf /tmp/example.sudoers
sudo install -m 0440 /tmp/example.sudoers /etc/sudoers.d/exampleThe second setting prevents dropped requests. Ubuntu ships PHP-FPM with process_control_timeout at 0, and with that value a reload kills the workers that are in the middle of a request. In the first test run, every deploy cost a request in flight at the moment of the reload: Nginx answered 502 Bad Gateway, on both 24.04 and 26.04. Giving workers time to finish fixes it:
sudo sed -i 's/^;process_control_timeout = 0/process_control_timeout = 30s/' /etc/php/8.5/fpm/php-fpm.conf
sudo php-fpm8.5 -t
sudo systemctl reload php8.5-fpmWith that in place, the same test saw no failed request across repeated deploys. One client requested a fast route in a loop, another kept a three-second request open, and a third loaded the home page. The slowest request during a swap took 0.25 seconds.
A complete Laravel deployment script
Save this as /var/www/example.com/deploy.sh, set REPO and BRANCH, and run it as the site account:
#!/usr/bin/env bash
# Zero-downtime deploy: build a new release, swap the current symlink,
# health-check it and roll back if it fails. Run it as the site's user.
set -euo pipefail
DOMAIN=example.com
REPO=git@github.com:acme/example.git
BRANCH=main
PHP_VERSION=8.5
KEEP=5
BASE=/var/www/$DOMAIN
NEW=$BASE/releases/$(date -u +%Y%m%d%H%M%S)
# Every php below, Composer's included, is the version the FPM pool runs.
php() { command "php$PHP_VERSION" "$@"; }
# Point current at $1 with a single rename(2), which is atomic.
point_current() {
ln -sfn "$1" "$BASE/current.next"
mv -T "$BASE/current.next" "$BASE/current"
}
# A 2xx passes and a 4xx or 5xx fails at once. Only "no answer yet" is retried.
healthy() {
local attempt status
for attempt in 1 2 3 4 5; do
status=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 --location \
--resolve "$DOMAIN:80:127.0.0.1" --resolve "$DOMAIN:443:127.0.0.1" \
"http://$DOMAIN/up") || true
case "$status" in
2??) return 0 ;;
000) sleep 2 ;;
*) echo "Health check: /up answered $status." >&2; return 1 ;;
esac
done
echo "Health check: /up did not answer." >&2
return 1
}
# One deploy at a time.
exec 9>"$BASE/deploy.lock"
flock -n 9 || { echo "Another deploy is running." >&2; exit 1; }
PREVIOUS=""
if [ -L "$BASE/current" ]; then
PREVIOUS=$(readlink -f "$BASE/current")
fi
# A release that never went live, or was rolled back, is removed on the way out.
LIVE_IS_NEW=0
trap '[ "$LIVE_IS_NEW" = 1 ] || rm -rf "$NEW"' EXIT
echo "Building $NEW"
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO" "$NEW"
cd "$NEW"
ln -s "$BASE/shared/.env" .env
rm -rf storage
ln -s "$BASE/shared/storage" storage
php "$(command -v composer)" install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-progress
if [ -f package.json ]; then
npm ci --no-audit --no-fund
npm run build
fi
php artisan storage:link
php artisan migrate --force
php artisan optimize
echo "Activating $NEW"
point_current "$NEW"
LIVE_IS_NEW=1
sudo -n systemctl reload "php$PHP_VERSION-fpm"
if ! healthy; then
if [ -n "$PREVIOUS" ]; then
echo "Rolling back to $PREVIOUS" >&2
point_current "$PREVIOUS"
LIVE_IS_NEW=0
sudo -n systemctl reload "php$PHP_VERSION-fpm"
fi
exit 1
fi
# Workers finish their current job, exit, and systemd starts them on the new release.
php artisan queue:restart
# Keep the newest $KEEP releases, and never the one that is live.
LIVE=$(readlink -f "$BASE/current")
ls -1 "$BASE/releases" | sort -r | tail -n +$((KEEP + 1)) | while read -r old; do
if [ "$BASE/releases/$old" != "$LIVE" ]; then
rm -rf "${BASE:?}/releases/$old"
fi
done
echo "Live: $LIVE"sudo -u example nano /var/www/example.com/deploy.sh
sudo chmod 755 /var/www/example.com/deploy.sh
sudo -u example /var/www/example.com/deploy.shTo deploy from CI, run the same command over SSH, either as the site account with a key in its authorized_keys or as an admin account that may sudo -u example. The script exits non-zero on any failure, so the CI job fails with it. The sections below go through what each part does.
Building the release: Composer, assets, migrations
The clone is shallow, because a release needs the files, not the history. The release then gets its two symlinks: .env to the shared file, and storage to the shared directory, replacing the empty one that came from Git.
The php function at the top pins every PHP command to the version the pool runs. Without it, composer and php artisan use whatever /usr/bin/php points at, which changes the day a second PHP version is installed from a third-party archive.
npm run build runs only when the repository has a package.json. npm ci refuses to run without a committed package-lock.json, and a new Laravel app ships without one, so run npm install once and commit the lock file. A new Laravel app uses Vite 8, which needs Node 20.19 or newer. Ubuntu 26.04's nodejs package is Node 22, which works. 24.04's is Node 18, which does not, so on 24.04 install a current Node another way or build the assets in CI and ship them.
migrate --force runs before the swap, while the previous release is still serving. That release will run against the new schema for a few seconds, so write migrations it can survive: add a column in one deploy and start using it in the next, and drop columns only once no live code reads them. php artisan optimize then caches config, events, routes and views into the new release.
Any failure up to this point ends the script, thanks to set -euo pipefail. The EXIT trap deletes the half-built release and the live site never notices. One test deploy had a Vite build error; the site kept serving the previous release and the failed directory was gone afterwards.
The atomic swap: ln -sfn, then mv -T
Many deploy scripts switch releases with ln -sfn new current. Whether that is atomic depends on which ln the server has, and Ubuntu 26.04 changed it. 26.04 ships the Rust uutils coreutils, and its ln -sfn deletes the old link and then creates the new one. GNU ln on 24.04 creates a temporary link and renames it over the old one:
# Ubuntu 26.04, uutils ln
$ strace -e trace=symlinkat,unlinkat,renameat ln -sfn b current
unlinkat(AT_FDCWD, "current", 0) = 0
symlinkat("b", AT_FDCWD, "current") = 0
# Ubuntu 24.04, GNU ln
$ strace -e trace=symlinkat,unlinkat,renameat ln -sfn b current
symlinkat("b", AT_FDCWD, "current") = -1 EEXIST (File exists)
symlinkat("b", AT_FDCWD, "Cu77JCtu") = 0
renameat(AT_FDCWD, "Cu77JCtu", AT_FDCWD, "current") = 0Between uutils' two calls, current does not exist. Flipping the link 2,000 times on 26.04 while two clients requested the site produced nine 404s with ln -sfn alone. With the script's two steps it produced none, and 24.04 produced none either way.
The two steps are: create the new link under a temporary name, then mv -T it over current. mv within one filesystem is a single rename(2), which replaces the old link atomically. The -T stops mv from treating current as the directory it points to and moving the new link inside the old release. It works the same on both releases, because 26.04 still ships GNU mv.
Reloading PHP-FPM and OPcache
PHP caches compiled scripts in OPcache and resolved paths in its realpath cache, and both work from the path PHP is given. If Nginx passes /var/www/example.com/current/public/index.php, that path is the same before and after the swap, and PHP keeps answering from the old release. In a test with Nginx's $document_root, the site was still serving the previous release more than three minutes after the swap.
The server block passes $realpath_root instead, which Nginx resolves through the symlink on every request, so PHP sees releases/20261021113519/public/index.php and the new release takes effect on the next request. The script still reloads PHP-FPM, which empties OPcache of releases that are no longer live and is required if you turn opcache.validate_timestamps off. With process_control_timeout set as above, the reload lets running requests finish.
sudo -n makes sudo fail at once if the sudoers rule does not match, instead of waiting for a password that a CI job will never type.
A health check that rolls back
Laravel 11 and newer register a health route at /up, described in the deployment docs. It boots the framework and answers 200, or 500 if booting throws. After the swap and the reload, the script requests it through Nginx on the server itself.
- --resolve. sends the request to 127.0.0.1 while keeping the real hostname, so Nginx picks this site's server block and TLS checks the real certificate name.
- --location. follows a redirect from HTTP to HTTPS. Without it, a site that forces HTTPS answers every check with a 301 and the check measures nothing.
- The status decides. A 2xx passes. A 4xx or 5xx fails immediately. Only 000, meaning no HTTP answer at all, is retried, up to five times.
The first version of this script retried every failure five times, two seconds apart. A broken test release, with a service provider that throws on every web request, served about 1,500 errors to the test client before the rollback. Failing on the first 500 cut that to two or three. A release that answers 500 will not start answering 200 if you wait.
Activating /var/www/example.com/releases/20261021113834
Health check: /up answered 500.
Rolling back to /var/www/example.com/releases/20261021113519On failure the script points current back at the previous release, reloads PHP-FPM, deletes the broken release and exits 1. On a first deploy there is nothing to go back to, so the release stays live and the script still fails.
/up proves the framework boots. It does not touch your database or any page of your own. If you want the check to cover more, point it at a route that does, and keep that route cheap.
Restarting queue workers
A queue worker is a long-running PHP process with the old release's code in memory. It keeps running that code until it restarts. php artisan queue:restart writes a timestamp to the cache, each worker exits after its current job, and systemd starts it again in current, which is now the new release. The script does this only after the health check passes, so workers never pick up a release that is about to be rolled back. With Horizon the line is php artisan horizon:terminate. The queue workers post has the systemd units this relies on.
Keeping five releases
Each release is a full copy with its own vendor/ and, if you build assets, node_modules/, so they add up. The last block deletes all but the newest KEEP, skipping whichever release is live. Five gives you four to roll back to.
Rolling back by hand
Rolling back is the same swap pointed at an older directory. Nothing is rebuilt:
sudo -u example ls -1 /var/www/example.com/releases
TARGET=/var/www/example.com/releases/20261021113519 # the release you want back
sudo -u example ln -sfn "$TARGET" /var/www/example.com/current.next
sudo -u example mv -T /var/www/example.com/current.next /var/www/example.com/current
sudo systemctl reload php8.5-fpm
sudo -u example php8.5 /var/www/example.com/current/artisan queue:restartA rollback restores code, not the database. If the release you are leaving ran a destructive migration, fix forward instead.
What Provisore does here
Provisore deploys with the same layout: a release directory per deploy, shared/.env, the symlink moved with a rename, a health check after the swap and an automatic rollback when the site answers with a server error. It keeps the five newest successful releases and restarts the site's daemons on the new release before the check, so a worker that cannot start fails the deploy too. Composer, npm, migrations and anything else your app needs live in a deploy script you edit, and a site can wait for its GitHub checks to pass before it deploys at all.