LARAVEL
July 9, 2026

How to Set Up Zero-Downtime Laravel Deployments

6 min read
Author
CloudStick Team
Backend Developer
Share this article
How to Set Up Zero-Downtime Laravel Deployments
CloudStick
Zero Downtime

Why a git pull deploy causes downtime

Zero-downtime deployment comes down to one idea: never modify the directory Nginx is serving. Instead, build the new version of your app in a fresh directory next to the live one, and switch traffic over with a single atomic symlink swap that takes microseconds.

The naive alternative — SSH in, run git pull, then composer install in the live directory — leaves your app broken for the entire duration of the deploy. While Composer swaps out the vendor directory, autoloaded classes go missing mid-request. While new migrations run against old code, requests hit mismatched schemas. On a slow deploy that window is thirty seconds to several minutes of 500 errors, and it happens on every single release.

Running php artisan down first turns the error page into a prettier 503, but it is still downtime. The structure below removes the window entirely, and once scripted it is no more work per release than the pull-based deploy was.

Share .env and storage across releases

Two things must never live inside a release directory: the .env file and the storage directory. If they did, every deploy would wipe your credentials, uploaded files, and logs. Both live in shared/ and get symlinked into each new release before it goes live:

$ cd /home/appuser/apps/myapp/releases/20260709101544
$ rm -rf storage && ln -s ../../shared/storage storage
$ ln -s ../../shared/.env .env

The rm -rf storage removes the empty skeleton that ships with the repository so the symlink can take its place. Run php artisan storage:link once against the shared storage as well, so public/storage in each release resolves to the same uploaded files.

WARNING

Migrations are the one step that cannot be made fully atomic — the database is shared by both the old and new release. Keep migrations backwards-compatible with the previous release (add columns before code that requires them, drop columns one release after the code stops using them). A destructive migration deployed with a symlink swap is still an outage.

A complete zero-downtime deploy script

Here is the whole flow as a single script. It clones the release, installs dependencies, links shared resources, warms Laravel's caches, runs migrations, and only then swaps the symlink:

#!/usr/bin/env bash
set -e
BASE=/home/appuser/apps/myapp
NEW=$BASE/releases/$(date +%Y%m%d%H%M%S)
git clone --depth 1 git@github.com:acme/myapp.git "$NEW"
cd "$NEW"
composer install --no-dev --optimize-autoloader --no-interaction
rm -rf storage && ln -s ../../shared/storage storage
ln -s ../../shared/.env .env
php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan migrate --force
# atomic swap: build the link under a temp name, rename over current
ln -sfn "$NEW" "$BASE/current_tmp" && mv -T "$BASE/current_tmp" "$BASE/current"

Tools like Deployer implement exactly this recipe with hooks and rollback built in, so you do not have to maintain the script yourself. If you host on CloudStick, its Git deployment feature covers the trigger side: connect the repository to the web application and a push to your production branch starts the deploy on the server — combined with this release structure, shipping becomes git push and nothing else. CloudStick's Web Application Clone is also the easy way to keep a staging copy of the site where you can rehearse a risky release before it touches production.

Reload PHP-FPM and restart workers after the swap

The symlink swap is not the last step — two caches still point at the old release. OPcache resolves symlinks when it caches a file, so PHP-FPM keeps executing the previous release's code until it is reloaded. And queue workers are long-running processes that booted the old code and will keep running it forever.

$ sudo systemctl reload php8.3-fpm # graceful: finishes in-flight requests
$ php artisan queue:restart # workers exit after current job

Use reload, not restart — reload spawns fresh workers with an empty OPcache while letting in-flight requests finish on the old ones, which is what keeps this step downtime-free. queue:restart sets a flag in the cache that tells each worker to exit cleanly after its current job; Supervisor then boots replacements that load the new release. If you skip either step, the deploy looks fine in the browser while background jobs quietly run week-old code.

Rollbacks and next steps

The rollback story is the quiet payoff of this whole structure: because the previous release still exists on disk, reverting a bad deploy is pointing current back at it and reloading PHP-FPM — a few seconds, no git archaeology under pressure. Keep the last four or five releases and prune older ones at the end of each deploy (ls -dt releases/* | tail -n +6 | xargs rm -rf) so the disk does not fill with dead copies.

To adopt this on an existing site, do it in one maintenance window: create the releases and shared directories, move the live app in as the first release, move .env and storage into shared/, create the symlinks, and repoint the web server's document root at current/public. Every deploy after that one is invisible to your users.

Then script it — the script above, Deployer, or a Git-push trigger — because a zero-downtime process only works when it runs identically every time. The releases structure, the shared symlinks, the atomic swap, and the FPM reload: four pieces, and none of them are optional.

Leave a comment
Full Name
Email Address
Message
Contents