Zero-Downtime Deployment with Deployer for PHP Apps (2026 Guide)

Khimananda Oli 8 min read Database
Zero-Downtime Deployment with Deployer for PHP Apps (2026 Guide)

By Khimananda Oli | Last reviewed: August 2026

The moment a PHP deploy means SSH-ing in and running git pull on the live directory, you are shipping downtime. For a few seconds the code is half-updated, the autoloader is stale, and a request that lands mid-pull gets a 500. Zero-downtime deployment with Deployer fixes this by building each release in its own directory and switching traffic over with a single atomic symlink flip. This guide covers the releases/shared/current model, a real deploy.php, safe migrations, cache warming, and one-command rollback. If you drive deploys from a pipeline, pair it with my GitLab CI/CD pipeline for Laravel walkthrough.

/srv/app/releases/98 (previous)99 (live)100 (building)shared/.envstorage/uploads/currentreleases/99shared/ is symlinked into every release, so secrets and uploads survive each deploy
The Deployer layout for zero-downtime deployment: timestamped releases/, a persistent shared/, and an atomic current symlink that always points at one complete release.

What does zero-downtime deployment with Deployer actually mean?

Deployer never edits your live directory in place. Every deploy creates a new numbered folder under releases/, builds it completely — clone, composer install, migrations, cache warming — and only then repoints current to it. Because your web server document root points at current/public, the switch is a single filesystem operation that either fully succeeds or does not happen at all. There is no window where the app is partly old and partly new.

Three directories do all the work:

  • releases/ — one timestamped folder per deploy, each a full, ready-to-serve checkout.
  • shared/ — the real .env, storage/, and any user uploads, symlinked into every release so state persists across deploys.
  • current — the atomic symlink that decides which release is live right now.

How do you install and configure Deployer for a PHP app?

Deployer is a single PHP tool you add per project. Require it with Composer, then generate a recipe:

composer require --dev deployer/deployer
vendor/bin/dep init

For a Laravel app, discard the generated stub and use the built-in Laravel recipe, which already knows how to run migrations and warm caches. A minimal, production-ready deploy.php looks like this:

<?php
namespace Deployer;

require 'recipe/laravel.php';

set('application', 'example.com');
set('repository', '[email protected]:you/example.git');
set('keep_releases', 5);
set('bin/php', '/usr/bin/php8.4');

add('shared_files', ['.env']);
add('shared_dirs', ['storage']);
add('writable_dirs', ['bootstrap/cache', 'storage']);

host('production')
    ->set('hostname', '203.0.113.10')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/srv/example.com')
    ->set('branch', 'main');

after('deploy:failed', 'deploy:unlock');

Two settings carry more weight than they look. set('bin/php', ...) pins the exact PHP binary so cache warming runs on the same runtime that serves requests — a mismatch here is a classic source of "works in build, breaks live" bugs. And keep_releases defines your rollback depth: keep at least three so a known-good release is always one flip away. If you are still standing up the box itself, my guide on deploying Laravel on an Ubuntu VPS with Nginx covers the PHP-FPM and web-server side that Deployer sits on top of.

What order do Deployer tasks run in during a deploy?

The Laravel recipe runs a fixed task pipeline. Understanding the order is what lets you reason about safety: everything that can fail happens before the symlink swap, so a failure aborts the release while the old one keeps serving.

deploy:lockone deploy at a timerelease + updatenew folder, git cloneshared + vendorsymlink env, composerartisan:migrate--force, pre-swapcache warmconfig, route, viewdeploy:symlinkatomic swap of currentcleanup + unlockprune old releases
The Deployer task pipeline: everything that can fail — clone, dependencies, migrations, cache warming — runs before deploy:symlink, the only step that changes what visitors see.

In order, a Laravel deploy does the following:

  1. deploy:lock — takes a lock so two deploys cannot race on the same host.
  2. deploy:release and deploy:update_code — create the new timestamped folder and fetch the target commit into it.
  3. deploy:shared and deploy:vendors — symlink the shared .env and storage/, then run composer install --no-dev --optimize-autoloader.
  4. artisan:migrate — runs php artisan migrate --force on the new release, before it goes live.
  5. Cache warmingartisan:config:cache, artisan:route:cache, and artisan:view:cache so the first request is fast.
  6. deploy:symlink — the atomic flip of current to the new release.
  7. deploy:cleanup and deploy:unlock — prune releases beyond keep_releases and release the lock.

Trigger the whole pipeline with one command:

vendor/bin/dep deploy production --verbose

Because the migration is the riskiest step, and running it before the swap means a failure never reaches users. In the Laravel recipe, artisan:migrate runs against the new release while current still points at the old one. If the migration throws, the deploy stops, deploy:failed fires, the lock is released, and the previous release keeps serving traffic untouched. Only a clean migration lets the pipeline proceed to the symlink flip.

There is a real constraint to respect: for the seconds between "migration applied" and "symlink flipped", the old code runs against the new schema. Keep migrations backward compatible — add columns and tables rather than renaming or dropping them in the same deploy. The safe pattern is expand-then-contract:

  • Deploy 1 (expand): add the new nullable column and write to both old and new.
  • Deploy 2: backfill data and switch reads to the new column.
  • Deploy 3 (contract): drop the old column once nothing references it.

This is the same discipline any real production database needs, Deployer or not — the atomic swap just makes the window small and predictable.

How do you roll back a bad deploy instantly?

Since every release under keep_releases is a complete, already-built checkout, rollback recompiles nothing — it repoints current at the previous release. That is why rollback is effectively instant and just as atomic as the deploy.

deployrelease 98release 99current99 (new)rollbackrelease 98release 99current98 (instant)
Rollback in a zero-downtime deployment is not a rebuild — it is an instant symlink flip of current back to the previous release.

The everyday commands are short:

vendor/bin/dep rollback production      # flip current back to the previous release
vendor/bin/dep releases production      # list releases and see which is live
vendor/bin/dep deploy:unlock production # clear a stale lock after an aborted deploy

One caveat mirrors the migration rule: code rolls back instantly, but the database does not. If a release ran a destructive migration, rolling back the symlink puts old code in front of a changed schema. This is the practical reason to keep migrations additive and reversible — so a symlink rollback is genuinely enough to recover.

How do you keep concurrent deploys and shared state safe?

Two mechanisms protect a live system when more than one deploy or engineer is involved:

  • Locks. deploy:lock writes a lock file at the start of every deploy and deploy:unlock removes it at the end. If a second deploy starts while one is running, it fails fast instead of interleaving. The after('deploy:failed', 'deploy:unlock') hook guarantees a crashed deploy does not leave a permanent lock.
  • Shared files and directories. Anything in shared_files or shared_dirs lives once under shared/ and is symlinked into each release. Your .env, logs, and user uploads therefore survive every deploy and every rollback — they are never part of the cloned code.

For teams, the cleanest setup is to never run dep deploy from laptops at all. Drive it from CI so deploys are logged, ordered, and reproducible; the same deploy.php works unchanged whether you invoke it by hand or from a pipeline runner.

Conclusion

Zero-downtime deployment with Deployer turns a PHP release from a held-breath moment into a routine event: build the release in isolation, run migrations before the swap, warm caches, flip one atomic symlink, and keep the last few releases ready for an instant rollback. Add a deploy.php to one project this week, run dep deploy a few times until it is boring, then wire it into CI. If you want a deployment pipeline designed and hardened for your stack, explore my DevOps and cloud services, browse deployment case studies, or get in touch to talk through your setup.

Frequently Asked Questions

Zero-downtime deployment is a release strategy where new code is built and prepared in an isolated directory, then activated in a single atomic switch. Live traffic is never routed to a partly-updated application, so users experience no errors or interruption during a deploy.

Deployer is an open-source deployment tool written in PHP. It automates cloning code, installing Composer dependencies, running migrations, warming caches, and flipping an atomic symlink, giving PHP and Laravel apps zero-downtime releases and instant rollbacks without custom shell scripts.

It builds each deploy in a fresh timestamped folder under releases/ while the old release keeps serving. Only after the build, migrations, and cache warming succeed does it repoint the current symlink to the new release — an atomic operation no request can catch mid-way.

releases/ holds one complete checkout per deploy; shared/ holds persistent state like .env, storage, and uploads that is symlinked into every release; and current is the symlink that points at whichever release is live right now. The web root points at current/public.

It works with any PHP app. Deployer ships recipes for Laravel, Symfony, and generic PHP projects, and you can write your own tasks. The releases/shared/current model and atomic symlink swap are framework-agnostic.

Run composer require --dev deployer/deployer, then vendor/bin/dep init to scaffold a deploy.php. For Laravel, require 'recipe/laravel.php' at the top of deploy.php to get migrations and cache warming built in.

Running migrations on the new release before flipping current means a failed migration aborts the deploy while the previous release keeps serving. Users never see a broken schema, and the deploy only completes when the database change succeeds cleanly.

No. Deployer rollback only moves the current symlink back to the previous release; it does not reverse migrations. Keep migrations backward compatible and additive so old code can safely run against the new schema during and after a rollback.

Run vendor/bin/dep rollback production. Because every retained release is a complete pre-built checkout, rollback just repoints the current symlink to the previous release instantly — nothing is recompiled. Keep at least three releases so a good one is always available.

keep_releases sets how many old releases Deployer keeps before pruning them during cleanup. It effectively defines your rollback depth. A value of three to five is common: enough history to roll back safely without filling the disk with old builds.

In the shared/ directory, declared via shared_files. Deployer symlinks the same .env into every release, so your secrets and configuration persist across deploys and rollbacks and are never part of the cloned repository.

deploy:lock writes a lock file at the start of a deploy and deploy:unlock removes it at the end, preventing two deploys from running on the same host at once. Adding after('deploy:failed', 'deploy:unlock') ensures a crashed deploy never leaves a permanent lock.

On servers with multiple PHP versions, the default binary may not match your production FPM version. Pinning bin/php ensures Composer, migrations, and cache warming all run on the exact runtime that serves requests, avoiding subtle build-versus-live differences.

CI is safer for teams. Running dep deploy from a pipeline makes deploys logged, ordered, and reproducible, and it avoids depending on any one engineer's machine. The same deploy.php works whether invoked by hand or by a CI runner.

A git pull updates the live directory in place, so for a few seconds the code, dependencies, and schema are out of sync and errors reach users. Deployer builds each release separately and switches atomically, so visitors only ever see a complete, working application.