Zero-downtime Laravel deploys on a plain VPS: the complete pattern

10 June 2026 · 8 min read

The naive deploy — git pull in the live directory, then composer install while visitors watch half-updated code throw 500s — is how most self-hosted Laravel apps ship. The fix has been known for a decade (it's what Envoyer, Deployer and Capistrano all do), but the complete pattern, with the gotchas, rarely fits in one page. Here it is.

The shape: atomic releases

/var/www/example.com/
├── current -> releases/20260610213000   # symlink = live site
├── releases/
│   ├── 20260608120000/                  # previous (rollback target)
│   └── 20260610213000/                  # live
└── shared/
    ├── .env                             # secrets live here, once
    └── storage/                         # uploads, logs, sessions

Each deploy builds a complete, self-contained release directory next to the live one, then flips the current symlink. The flip is ln -sfn — one atomic syscall. No visitor ever sees a half-deployed state, and rollback is pointing the symlink back.

The sequence

  1. Stage code into releases/<timestamp> (rsync from CI, or git clone).
  2. Link shared state: delete the release's own storage/ and .env, symlink them from shared/. Secrets and uploads survive every deploy.
  3. composer install --no-dev --optimize-autoloader inside the release. Every release owns its vendor/ — rollbacks never run composer.
  4. Migrate: php artisan migrate --force. Critically, this happens before the switch — if it fails, the live site is untouched.
  5. Cache: config:cache, route:cache, view:cache. The release never changes after build, so these can't go stale.
  6. Switch: ln -sfn $RELEASE_DIR current.
  7. Reload php-fpm, restart queue workers (the two gotchas below).
  8. Smoke test and prune old releases (keep ~5).

Gotcha #1: opcache and the symlink

You flip the symlink and... nothing changes. PHP-FPM is still serving the old release. Two caches conspire: opcache (compiled bytecode keyed by resolved path) and PHP's realpath cache. The fix is one nginx line that almost every tutorial gets wrong:

location ~ \.php$ {
    fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
    #                              ^^^^^^^^^^^^^^ not $document_root
}

$realpath_root resolves the symlink per request, so the new release's files are new paths to opcache — no stale bytecode possible. Belt-and-braces: a graceful systemctl reload php8.3-fpm after the switch (reload, not restart — zero dropped requests).

Gotcha #2: queue workers hold old code in memory

Your deploy is live, but password-reset emails still render the old template. Queue workers are long-running PHP processes; they loaded the old release at boot and will happily run it forever. Every deploy must restart them. With a systemd template unit (laravel-queue@example.com), that's:

sudo systemctl restart laravel-queue@example.com

Give the deploy user exactly that permission in sudoers — nothing broader. If you use Horizon, php artisan horizon:terminate does the same job gracefully.

Gotcha #3: migrations and the rollback story

Code rollback is instant (the old release still exists, vendor and all). The database is the hard part: releases share one schema. The discipline that makes rollbacks safe is additive-first migrations — deploy N adds the new column and writes both old and new; deploy N+1, days later, drops the old one. Either deploy can then be rolled back with code alone. Destructive migrations get a tested down() and a pre-deploy database backup, or they don't ship.

Wiring it to CI

Build assets in CI (the server doesn't need node), rsync to the server, run the deploy script over SSH with a dedicated key that can only do deploy things. The whole workflow is ~60 lines of GitHub Actions YAML, and from then on git push is your deploy button — same engine Envoyer charges $10/mo for, except you can read every line of it.

Don't want to write the scripts yourself? The Production Laravel Ops Kit contains this exact deploy pipeline — deploy.sh, rollback.sh, the nginx template, the systemd units and the GitHub Actions workflow — plus provisioning, backups, monitoring and incident runbooks. $39, use it on unlimited servers.

The complete checklist

  • Atomic releases with current symlink; shared .env + storage/
  • $realpath_root in nginx fastcgi config
  • Graceful php-fpm reload after switch
  • Queue workers restarted on every deploy
  • Migrations before the switch; additive-first discipline
  • Smoke test after the switch; keep 5 releases for instant rollback

That's the whole pattern. It fits in ~150 lines of bash, runs anywhere Ubuntu runs, and once it's in place you'll deploy on a Friday afternoon without a second thought. (You still shouldn't. But you could.)