log in
consulting hosting industries the daily tools about contact

Zero-Downtime Laravel Deploys Without the Orchestrator Tax

You don't need Kubernetes to ship without dropping requests. Here's the atomic release pattern I use on single servers, built in plain bash and a symlink.

The first time a client called me mid-afternoon because their e-commerce checkout was throwing 500s during a deploy, I was already halfway through a git pull on the live server. Classic. That was enough — I moved everything to atomic releases that week and I haven't had a deploy-window outage since.

The good news: you don't need Envoyer (though it's fine), you don't need Laravel Forge's deploy hooks, and you absolutely don't need a Kubernetes cluster for a single-server app doing a few hundred requests per minute. The pattern is old, borrowed from Capistrano circa 2007, and it works exactly as well today.

What "Atomic" Actually Means Here

The problem with git pull + composer install + php artisan migrate in place is that there's a window — sometimes 30 seconds, sometimes two minutes — where your webroot is in a half-baked state. New code, old vendor directory. Old views, new config. PHP-FPM is still serving requests the whole time.

The atomic release pattern sidesteps this entirely. You build each release in an isolated directory, do all your install and asset compilation work there while the old release is still live, then flip a single symlink. From Nginx's perspective, current points somewhere new. PHP-FPM reloads. The switch is sub-millisecond.

No requests hit a half-built release. Ever.

The Directory Layout

I set this up under /var/www/myapp with a structure like this:

/var/www/myapp/
├── releases/
│   ├── 20250115143000/   ← previous release
│   └── 20250116091500/   ← current release
├── shared/
│   ├── .env
│   ├── storage/
│   └── public/uploads/
└── current -> releases/20250116091500   ← the symlink

Nginx's root points to /var/www/myapp/current/public. That never changes. The current symlink is what changes.

shared/ holds anything that needs to persist across releases — your .env, the storage/ directory, user-uploaded files. Each new release gets symlinks into shared/ before it goes live.

The Deploy Script

I keep this as deploy.sh in the project root, committed to the repo. It runs on the server via SSH from my local machine or a CI runner.

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/var/www/myapp"
RELEASES_DIR="$APP_DIR/releases"
SHARED_DIR="$APP_DIR/shared"
RELEASE="$(date +%Y%m%d%H%M%S)"
RELEASE_DIR="$RELEASES_DIR/$RELEASE"
REPO="git@github.com:nwos/myapp.git"
BRANCH="main"
KEEP_RELEASES=5

echo "==> Creating release $RELEASE"
mkdir -p "$RELEASE_DIR"

echo "==> Cloning $BRANCH"
git clone --depth=1 --branch "$BRANCH" "$REPO" "$RELEASE_DIR"

echo "==> Linking shared files"
rm -rf "$RELEASE_DIR/storage"
ln -nfs "$SHARED_DIR/storage" "$RELEASE_DIR/storage"
ln -nfs "$SHARED_DIR/.env" "$RELEASE_DIR/.env"

echo "==> Installing dependencies"
cd "$RELEASE_DIR"
composer install \
  --no-dev \
  --no-interaction \
  --prefer-dist \
  --optimize-autoloader \
  --quiet

echo "==> Building assets"
npm ci --silent
npm run build --silent

echo "==> Caching config and routes"
php artisan config:cache
php artisan route:cache
php artisan view:cache

echo "==> Running migrations"
php artisan migrate --force

echo "==> Activating release"
ln -nfs "$RELEASE_DIR" "$APP_DIR/current"

echo "==> Reloading PHP-FPM"
sudo systemctl reload php8.3-fpm

echo "==> Pruning old releases (keeping $KEEP_RELEASES)"
cd "$RELEASES_DIR"
ls -1dt */ | tail -n +$((KEEP_RELEASES + 1)) | xargs rm -rf

echo "==> Done. Release $RELEASE is live."

A few things worth calling out:

ln -nfs is the line that does the actual cutover. The -n flag treats the destination as a normal file if it's a symlink, -f forces the overwrite, -s makes it symbolic. Without -n, you can end up with a symlink nested inside itself on repeated deploys — ask me how I know.

systemctl reload php8.3-fpm sends SIGUSR2, which tells FPM to finish in-flight requests and then reload its worker pool with the new codebase. It's graceful. restart is not graceful. Don't use restart here.

The Gotchas That Will Bite You

Migrations run before the symlink flips. This is intentional but it means your migration has to be backward-compatible with the old code, because old code is still serving traffic when the migration runs. Add columns nullable. Don't rename columns in a single deploy. Drop columns in a follow-up deploy after the new code has been live for a cycle. If you're not doing expand/contract migrations yet, this pattern will force you to start.

Opcache will lie to you. PHP's opcache holds compiled bytecode in memory. After the symlink flips, opcache may still serve cached bytecodes from the old release path — because the symlink resolved to a different inode than FPM expects. The systemctl reload php8.3-fpm handles this in most setups, but I've seen shared hosting environments and some container builds where it doesn't. If you keep getting stale behavior after a deploy, add php artisan opcache:clear (via a package like appstacks/opcache) or set opcache.revalidate_freq=0 in dev and verify you're actually reloading, not just restarting.

Queue workers are still running old code. This one catches people. Your Horizon or plain queue workers hold the old release in memory until they restart. After the symlink flips, add:

php artisan horizon:terminate
# or, if you're using plain workers supervised by Supervisor:
sudo supervisorctl restart myapp-worker:*

Horizon will restart itself after horizon:terminate if Supervisor is watching it. The important thing is that jobs dispatched by new code but processed by old workers can fail in subtle ways — especially if you've changed a job class signature.

Composer's vendor directory is per-release. This is the whole point, but it means each release is ~200-400MB on disk depending on your dependency tree. With five releases retained, that's up to 2GB. On a $20 VPS with 40GB disk it's a non-issue. On a box that's also running a database and storing uploads, keep an eye on it. I set KEEP_RELEASES=5 and have a cron that alerts me if /var/www exceeds a threshold.

The .env symlink and config:cache. When you run php artisan config:cache inside the release directory, Laravel reads the .env via the symlink and bakes the values into bootstrap/cache/config.php. That cached file lives inside the release directory. If your .env changes between when you cache and when you go live, you'll serve stale config. The fix: treat .env changes as a separate operation, and always run config:cache as the last step before the symlink flips, not before.

Rolling Back

This is where the pattern earns its keep. Rollback is a symlink swap and a reload:

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/var/www/myapp"
RELEASES_DIR="$APP_DIR/releases"

# Second-newest release
PREVIOUS=$(ls -1dt "$RELEASES_DIR"/*/ | sed -n '2p')

echo "==> Rolling back to $PREVIOUS"
ln -nfs "$PREVIOUS" "$APP_DIR/current"
sudo systemctl reload php8.3-fpm
echo "==> Rolled back."

That's it. Fifteen seconds to roll back. No rebuilding, no pulling, no composer install. The old release is already fully built on disk.

I've used this on a healthcare client's portal where downtime genuinely costs billable hours. Being able to roll back in under 30 seconds while I diagnose the issue is worth a lot.

When I'd Reach For This

This pattern is my default for:

  • Single-server Laravel apps with moderate traffic (up to a few thousand req/min)
  • Clients on managed VPS where adding an orchestrator isn't in scope or budget
  • Apps where I own the server and can set up the directory structure once
  • Greenfield projects that will eventually move to something fancier — this pattern teaches the discipline before you add complexity

I wouldn't bother hand-rolling this if:

  • I'm already running Forge or Ploi — their built-in zero-downtime deploys do this for you and integrate with their server management
  • The app spans multiple servers behind a load balancer — at that point you need coordinated deploys and probably want a proper tool
  • The team is large enough that a shared deploy pipeline in GitHub Actions or similar is worth the setup cost — you'd add this pattern as a step in that pipeline, not as a standalone script

Envoyer is genuinely good if you're in the Laravel ecosystem and want a UI, notifications, deployment hooks, and heartbeat monitoring baked in for $10/month. I'm not opposed to it. I just find that a 100-line bash script I wrote myself has exactly zero surprising behaviors at 2am.

The Broader Point

Zero-downtime deploys aren't a cloud-native feature. They're a directory layout and a symlink. Engineers have been doing this since before AWS existed.

Build the discipline on a single server with a script you understand line by line. When you eventually do move to containers or a proper orchestrator, you'll know exactly what they're automating — and you'll know when they're doing it wrong.

Need help shipping something like this? Get in touch.