DEV Community

Abiodun Paul Ogunnaike
Abiodun Paul Ogunnaike

Posted on

Mastering Zero-Downtime Deployments with Laravel Deployer

How I achieved zero-downtime deployments using PHP Deployer for my Laravel app, and the critical caching and Horizon worker issues I had to solve.

Deploying a Laravel application shouldn't mean taking your site offline. For a long time, I used simple git pull scripts or basic FTP, which often resulted in momentary downtime or broken states if a user visited exactly while composer install was running.

To solve this for my platform, CelebrateMe, I migrated to Deployer a powerful, PHP-based deployment tool that makes zero-downtime deployments incredibly easy.

However, the journey wasn't entirely smooth. I ran into a couple of frustrating issues regarding Deployer's Git caching and Laravel Horizon worker memory that had me pulling my hair out.

Here is how I set it up alongside GitHub Actions to automate the process, the issues I faced, and exactly how I fixed them.


Triggering Deployments with GitHub Actions

To fully automate the CI/CD pipeline, I connected Deployer to GitHub Actions. Instead of manually running dep deploy from my local machine, I configured a GitHub Actions workflow that runs whenever I push to the main or beta branch.

The workflow handles building my frontend assets and then securely triggers Deployer (via an SSH key and secret tokens) to pull the latest code and deploy it to the server. This means every push automatically triggers a zero-downtime deployment without any manual intervention!

How Zero-Downtime Deployment Works

Deployer achieves zero-downtime by maintaining a specific directory structure on your server:

/www/wwwroot/my-app/
├── current -> /www/wwwroot/my-app/releases/21  (Symlink to the latest release)
├── releases/
│   ├── 19/
│   ├── 20/
│   └── 21/  (The active release)
└── shared/
    ├── .env
    └── storage/
Enter fullscreen mode Exit fullscreen mode

When you run dep deploy, Deployer does not overwrite your live code. Instead, it:

  1. Creates a brand new folder in releases/ (e.g., releases/22).
  2. Clones your GitHub repository into that new folder.
  3. Runs composer install, npm run build, and php artisan migrate entirely in the background inside releases/22.
  4. Finally, it updates the current symlink to point to releases/22 in a fraction of a second.

Because the web server (Nginx/Apache) points to the current symlink, the transition is instantaneous. Zero downtime!


The Problem: Deployer's Git Cache (.dep/repo)

By default, to make deployments faster, Deployer tries to cache your Git repository on the server inside a hidden .dep/repo directory. Instead of doing a full git clone every time, it fetches the changes into this cached repo and then copies them to the new release folder.

The Issue I Faced

After a few successful deployments, my deployments suddenly started failing mysteriously. Deployer would get stuck during the deploy:update_code step, complaining about uncommitted changes, corrupted git indices, or simply failing to pull the latest commits from the main branch.

Because the cached repository in .dep/repo had gotten out of sync or corrupted by a previous interrupted deployment, Deployer was completely locked up.

The Solution: The Clone Strategy

I realized that for my scale, a fresh clone takes only a few seconds anyway, and the reliability of a fresh clone far outweighs the slight speed boost of the Git cache.

I bypassed the cache completely by explicitly telling Deployer to use the clone strategy instead of the default cache strategy.

In my deploy.php file, I added this single line of configuration:

// Force Deployer to freshly clone the repository every time
set('update_code_strategy', 'clone');
Enter fullscreen mode Exit fullscreen mode

Once I added this, Deployer stopped relying on the .dep/repo cache. It simply ran a fresh git clone into the new release directory every time. The deployments became 100% reliable and I never saw a Git cache corruption error again.


The Second Gotcha: Laravel Horizon and "Ghost" Code

Even after my deployments were succeeding perfectly and the current symlink was pointing to my new code, I noticed something terrifying in my error logs.

My queue workers (Laravel Horizon) were crashing with errors referencing line numbers and logic from old code that I had completely removed in my latest commit!

Why did this happen?

When Deployer updates the current symlink, your web server (Nginx) immediately starts serving the new PHP files for incoming HTTP requests.

However, Laravel Horizon (and queue workers) are long-running PHP daemon processes. They are started once and kept alive in memory. When you deploy new code, those long-running processes do not automatically know that the files on the hard drive have changed. They continue executing the old classes that were loaded into memory when the worker first started (potentially days ago!).

The Solution: Graceful Termination

To fix this, you must tell Horizon to gracefully terminate itself after a deployment finishes. If you are using a process monitor like Supervisor, it will instantly restart the Horizon process, and when it boots back up, it will load the fresh code from the new current symlink.

To automate this, hook into Deployer's lifecycle. In deploy.php, define a task to restart Horizon and run it right after the symlink is updated:

task('horizon:terminate', function () {
    run('cd {{release_or_current_path}} && php artisan horizon:terminate');
});

// Run this task automatically after the new code is live
after('deploy:symlink', 'horizon:terminate');
Enter fullscreen mode Exit fullscreen mode

(Note: Don't use horizon:purge or queue:restart if you are specifically using Horizon. horizon:terminate is the official, safe way to instruct the master process to wrap up its current jobs and shut down).


Conclusion

Deployer is an absolute game-changer for deploying PHP applications, but understanding how it interacts with the server environment is crucial.

If you're setting up Deployer for a Laravel app, save yourself a headache and remember these two rules:

  1. Use set('update_code_strategy', 'clone'); if you run into .dep cache corruption.
  2. Always run php artisan horizon:terminate after your deploy:symlink step to ensure your background workers aren't running ghost code from memory!

Happy deploying!!!

Top comments (0)