Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Zero-Downtime Deployment with Deployer for PHP Apps

By Kokil Thapa | Last reviewed: September 2026

Users notice downtime the moment a deploy breaks mid-request. Zero-Downtime Deployment with Deployer for PHP Apps solves that by keeping the current release live while the next one is built in a separate directory. On a real client project, I have shipped this pattern with Deployer 7 and GitLab CI across multiple Laravel legal-tech portals on shared Ubuntu servers. The approach works for Laravel 12/13, Symfony 8.1, and WordPress 7.1 when you treat shared state, opcache, and migrations as first-class concerns—not afterthoughts bolted onto a copy script.

What Is Zero-Downtime Deployment with Deployer for PHP Apps?

Deployer is a PHP deployment tool built around the Capistrano release model. Each deploy creates a new folder under releases/. Shared files—.env, storage/, uploaded media—live outside any single release. When the build finishes, Deployer points the current symlink at the new release in one filesystem operation.

That symlink swap is atomic on Linux. Nginx or Apache serves /var/www/example.com/current/public. Requests already in flight finish on the old release. New requests read the updated symlink and land on the new code. No half-written files appear in the document root mid-deploy.

Deployer Release Layoutreleases/20260908_143022releases/20260908_151045shared/.env, storage, logscurrent →new releaseWeb rootpublic/index.phpAtomic symlink swap = zero downtime
Zero-Downtime Deployment with Deployer for PHP Apps keeps shared state outside timestamped release folders.

The model differs from rsync-over-live-code or FTP uploads. Those approaches overwrite files while Apache serves them. Deployer never touches the live tree until the new release is complete. That distinction matters for production PHP applications where a broken deploy at 11 PM on a Friday costs real money.

Core Concepts You Must Understand

  • Releases — immutable deploy artefacts identified by timestamp or Git SHA.
  • Shared — persistent directories symlinked into every release (storage, uploads).
  • Current — the single symlink the web server document root targets.
  • Tasks — ordered steps: clone, composer install, migrate, cache, symlink swap, reload.
  • Hooksbefore, after, and failed events around each task.

Deployer 7 ships recipes for Laravel, Symfony, Yii, and WordPress. You can also write a custom deploy.php for legacy CodeIgniter or plain PHP. The release model stays the same regardless of framework.

How Do You Install and Configure Deployer 7 for PHP?

Install Deployer globally with Composer 2.10 on your local machine or CI runner. Pin the major version so pipeline behaviour stays predictable across team members.

composer global require deployer/deployer:^7.0
dep --version

Initialize a recipe inside your project root. Laravel projects get a head start from the built-in recipe:

cd /path/to/your-app
dep init --recipe=laravel

A minimal deploy.php for Laravel 13 on PHP 8.3+ looks like this:

<?php
namespace Deployer;

require 'recipe/laravel.php';

set('application', 'my-app');
set('repository', 'git@gitlab.com:team/my-app.git');
set('keep_releases', 5);

host('production')
    ->setHostname('203.0.113.10')
    ->setRemoteUser('deploy')
    ->setDeployPath('/var/www/my-app')
    ->set('branch', 'main');

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

Run your first deploy from the project root:

dep deploy production

Deployer SSHes into the server, creates the release folder, runs tasks, and swaps the symlink. If anything fails, the deploy:unlock hook releases a deploy lock so the next attempt is not blocked.

Server Prerequisites

Your Ubuntu 22/24 VPS needs a dedicated deploy user, SSH key auth, and correct directory ownership. I follow the same baseline described in our Ubuntu server setup guide for PHP apps:

  1. Create user deploy with passwordless sudo limited to service reload commands.
  2. Add your CI runner's public key to ~deploy/.ssh/authorized_keys.
  3. Ensure PHP 8.3 or 8.5 FPM, Composer 2.10, and Git are installed on the server.
  4. Point the vhost document root at {{deploy_path}}/current/public.
  5. Open port 22 from your CI runner IP only—never expose SSH to the world.

File ownership is a recurring production headache. The deploy user owns release directories. PHP-FPM runs as www-data. Shared storage/ must be group-writable by both. A typical fix:

sudo usermod -aG www-data deploy
sudo chown -R deploy:www-data /var/www/my-app/shared/storage
sudo chmod -R 775 /var/www/my-app/shared/storage

What Shared Directories and Environment Files Should You Configure?

Shared paths are the most common source of "works on deploy, breaks on next deploy" bugs. Anything that must survive across releases belongs in shared/, not inside a release folder.

For Laravel, the default recipe already shares these paths:

add('shared_files', ['.env']);
add('shared_dirs', ['storage']);

Extend that list when your app writes outside Laravel defaults:

add('shared_dirs', [
    'storage',
    'storage/app/public',
    'bootstrap/cache',
]);

add('shared_files', [
    '.env',
    'storage/oauth-private.key',
    'storage/oauth-public.key',
]);

WordPress and Symfony projects need different shared paths. A Symfony 8.1 app typically shares .env.local.php, var/log, and public/uploads. WooCommerce 11.1 on WordPress shares wp-content/uploads and sometimes wp-content/cache.

Deployer Task PipelineGit cloneSharedsymlinksComposerArtisanmigrateSwapSite still serves OLD releaseduring all steps abovePHP-FPM reload + opcache resetNew release now live
Deployer builds the full release before the atomic symlink swap triggers PHP-FPM reload.

Never commit .env to Git. Place it once in shared/.env on the server. Deployer symlinks it into each release automatically. For secrets rotation, update the shared file and redeploy—no release folder edit required.

On sister sites like Notary Kathmandu and Court Marriage In Nepal, I use the same shared-directory layout across a Deployer 7 + GitLab CI pipeline on shared EC2 infrastructure. Consistency reduces midnight debugging.

How Do Database Migrations Fit Into a Zero-Downtime Deploy?

Code deploys are atomic. Database schema changes are not. Running a breaking migration before new code is live will crash the old release. Running it too late leaves new code unable to boot.

The safe pattern for additive changes is backward-compatible migrations first, deploy second, cleanup later. Our Laravel migrations best practices guide covers expand-contract in detail. The short version:

  1. Add new columns as nullable before deploy.
  2. Deploy code that reads/writes both old and new columns.
  3. Backfill data in a separate job or migration.
  4. Deploy code that uses only the new column.
  5. Drop the old column in a later release.

Disable automatic migration during deploy when a DBA must review first:

task('artisan:migrate', function () {
    writeln('<comment>Skipping migrate — run manually</comment>');
});

For zero-downtime at scale, read database migrations at scale. Queue workers deserve special attention. Restart them after symlink swap so they load new job classes:

after('deploy:symlink', 'artisan:queue:restart');

Horizon and Laravel 13 queue workers cache compiled code in memory. A symlink swap alone does not restart them. Missing this step produces "class not found" errors that look random because only some workers hold stale bytecode.

PHP opcache stores compiled bytecode in worker memory. After Deployer swaps current, FPM workers may still serve old cached files for minutes. Users see a mix of old and new behaviour until every worker respawns.

Reload PHP-FPM after the symlink swap. Add this to deploy.php:

desc('Reload PHP-FPM');
task('php-fpm:reload', function () {
    run('sudo systemctl reload php8.3-fpm');
});

after('deploy:symlink', 'php-fpm:reload');

Grant the deploy user passwordless reload via sudoers:

deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload php8.3-fpm

Tune opcache for production as described in our PHP opcache configuration guide. Key settings on PHP 8.5:

opcache.enable=1
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.max_accelerated_files=20000

With validate_timestamps=0, opcache never checks file mtimes. That is correct for Deployer-based deploys because you always reload FPM after swap. Leaving validation on adds unnecessary stat calls on every request.

Apache with mod_php is rare in 2026 production stacks. If you still run it, use apachectl graceful instead of FPM reload. Nginx does not cache PHP—it only proxies to FPM—so the FPM reload is the critical step.

Comparison: Deploy Strategies for PHP Production Apps

StrategyDowntimeRollback SpeedComplexityBest For
FTP / rsync overwriteHigh riskSlow (restore backup)LowNever on production
Git pull on live treeMedium riskGit revert + manualLowStaging only
Deployer symlink releasesNoneSeconds (dep rollback)MediumLaravel, Symfony, WP
Blue-green (two full stacks)NoneInstant traffic switchHighHigh-traffic SaaS
Docker/Kubernetes rollingNoneRevision rollbackHighContainer-native teams

For most PHP agencies and SMB clients in Nepal, Deployer hits the sweet spot. Blue-green needs double infrastructure—often Rs 8,000–15,000/month (~USD 60–110) extra on a VPS budget that already hurts. Deployer adds zero extra servers. See our blue-green deployment explainer if you outgrow symlink releases.

Deployment Risk vs Rollback SpeedHigh riskLow riskSlow rollbackFast rollbackFTPGit pullDeployer7BluegreenDeployer: low risk, fast rollback, moderate complexity
Deployer 7 balances zero-downtime deployment with seconds-fast rollback for typical PHP production workloads.

How Do You Wire Deployer Into GitLab CI for Automated Deploys?

Manual dep deploy from a laptop works until someone forgets which branch was live last Tuesday. GitLab CI runs the same deploy command on every merge to main. I use this pattern on multiple production sites.

stages:
  - test
  - deploy

variables:
  COMPOSER_ALLOW_SUPERUSER: "1"

test:
  stage: test
  image: php:8.3-cli
  script:
    - composer install --no-interaction
    - vendor/bin/phpunit

deploy_production:
  stage: deploy
  image: php:8.3-cli
  only:
    - main
  before_script:
    - apt-get update && apt-get install -y rsync openssh-client
    - composer global require deployer/deployer:^7.0
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ssh-keyscan 203.0.113.10 >> ~/.ssh/known_hosts
  script:
    - ~/.composer/vendor/bin/dep deploy production -vvv

Store SSH_PRIVATE_KEY as a masked CI variable. Never embed keys in .gitlab-ci.yml. Build frontend assets in CI when the server lacks Node.js 26 LTS:

build_assets:
  stage: test
  image: node:26
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - public/build/

Commit built assets or pass them as CI artefacts. Several sites I maintain have no Node on the production VPS. Vite 8.x output lands in Git or rsyncs into the release during deploy. That is boring infrastructure—and boring is good for business-critical systems.

For monorepos with multiple PHP apps, see CI/CD for monorepos. Symfony teams should read the dedicated Symfony deployment on Ubuntu VPS walkthrough alongside this guide.

Rollback When Something Goes Wrong

Deployer keeps the last N releases (default 5). Roll back in seconds:

dep rollback production

This repoints current to the previous release and reloads services. It does not reverse database migrations—that requires a forward migration or restore from backup. Read how to roll back a failed deployment safely before you need it at 2 AM.

Add a health-check task before symlink swap on critical apps:

task('deploy:health', function () {
    $result = run('cd {{release_path}} && php artisan --version');
    if (empty($result)) {
        throw new \Exception('Health check failed');
    }
});

before('deploy:symlink', 'deploy:health');

HTTP health checks against a temporary URL are even better. Some teams expose /health on an internal port before swap. That catches missing .env keys and broken Composer autoloaders early.

GitLab CI + Deployer Production FlowGit pushmain branchGitLab CItest + builddep deploySSH to VPSLive sitezero downtimeUbuntu VPS — release folders + shared statereleases/shared/PHP-FPMRollbackdep rollback repoints current symlink in seconds
GitLab CI runs tests, then Deployer performs Zero-Downtime Deployment with Deployer for PHP Apps over SSH.

What Production Gotchas Break Zero-Downtime Deploys?

Deployer handles the mechanics. These operational mistakes still cause outages. I have hit most of them on live systems.

Stale Cron Paths

Laravel scheduler cron must target current, not a hardcoded release path:

* * * * * cd /var/www/my-app/current && php artisan schedule:run >> /dev/null 2>&1

A cron pointing at releases/20260801_120000 silently stops running scheduled tasks after five deploys. The app looks fine until invoices stop sending.

Deploy Locks Left Open

Deployer acquires a lock file to prevent concurrent deploys. A killed CI job can leave the lock stuck. Clear it manually:

dep deploy:unlock production

The after('deploy:failed', 'deploy:unlock') hook prevents most cases. Monitor CI job timeouts anyway.

Redis and Cache Staleness

Config and route caches baked into a release can reference old paths. Clear caches during deploy:

after('deploy:symlink', 'artisan:config:cache');
after('deploy:symlink', 'artisan:route:cache');
after('deploy:symlink', 'artisan:view:cache');

For Redis-backed sessions and cache, read our Redis caching guide. Session drivers that store files in storage/framework/sessions work fine with shared storage. Database sessions need no deploy special-casing.

Feature Flags vs Feature Branches

Deploying incomplete features to production is safe when code paths are gated. Long-lived feature branches deployed to staging only reduce risk differently. Compare approaches in feature branch deployment workflow. The twelve-factor app principles—especially config in environment and disposability—align directly with Deployer's release model.

Validate your deploy.php syntax before pushing with the JSON formatter and syntax tools when piping CI output into structured logs. For server hardening beyond deploy, see Linux system administration and ongoing support and maintenance services.

Official references worth bookmarking: the Deployer 7 getting started documentation, the Laravel 12 deployment guide, and PHP's opcache configuration manual.

Key Takeaways

  • Deployer achieves zero downtime by building a full release, then swapping the current symlink atomically on Linux.
  • Share .env, storage, and upload directories outside release folders—never rsync over live code.
  • Reload PHP-FPM after every symlink swap when opcache.validate_timestamps=0 in production.
  • Run backward-compatible migrations before deploy; restart queue workers after swap.
  • Wire dep deploy into GitLab CI with SSH keys as masked variables, not committed secrets.
  • Keep five releases and practice dep rollback production before you need it during an incident.

People Also Ask

Does Deployer work with WordPress and Symfony, not just Laravel?

Yes. Deployer 7 ships recipes for Laravel, Symfony, Yii, CakePHP, Magento 2.4.x, and WordPress. Custom PHP apps need a bare deploy.php with manual shared-dir configuration. The symlink release model is framework-agnostic.

How is Deployer different from Envoyer or Forge?

Deployer is open-source CLI tooling you run locally or in CI. Envoyer and Laravel Forge are hosted services with UI and server provisioning. Deployer costs nothing beyond your existing VPS—typically Rs 1,500–3,000/month (~USD 11–22) on Nepali hosts—and gives full control over task hooks.

Can you achieve zero downtime without container orchestration?

Absolutely. Symlink-based releases on a single VPS are zero-downtime for PHP request/response workloads. Containers add value at scale but are not required. Most SMB and agency PHP apps in Nepal run fine on one Ubuntu box with Deployer 7.

What PHP version should you run with Deployer in 2026?

Use PHP 8.3 minimum for Laravel 13, or PHP 8.2 for Laravel 12 supported through February 2027. Symfony 8.1 requires PHP 8.4.1+. Match the FPM reload command in your Deployer task to the installed version (php8.3-fpm vs php8.5-fpm).

Ship Zero-Downtime PHP Deploys With Confidence

Zero-Downtime Deployment with Deployer for PHP Apps is the most practical path from "FTP and pray" to production-grade releases on a budget. You get atomic swaps, fast rollbacks, and a deploy script that lives in Git beside your application code. Start with a staging VPS, run five practice deploys including one intentional rollback, then wire GitLab CI once the manual flow is boring.

If you want help setting up Deployer on your Laravel, Symfony, or WordPress stack—or migrating off manual FTP—get in touch for a deployment audit. You can also browse the portfolio for live examples or read more on the blog. For greenfield builds where deploy architecture is decided upfront, see web development services and about the author.

Frequently Asked Questions

Deployer is a PHP deployment tool built around the Capistrano release model. Each deploy creates a new timestamped folder under releases/, while shared files like .env and storage/ live outside any single release. When the build finishes, Deployer atomically repoints the current symlink at the new release on Linux. Nginx or Apache serves {{deploy_path}}/current/public, so requests in flight finish on the old release and new requests land on complete code—never half-written files mid-deploy.

Install Deployer globally with Composer 2.10: composer global require deployer/deployer:^7.0. Pin the major version so pipeline behaviour stays predictable. From your project root, run dep init --recipe=laravel to generate deploy.php. Set application name, Git repository, keep_releases (typically 5), and production host details including hostname, remote user deploy, deploy path, and branch. Add after('deploy:failed', 'deploy:unlock') so failed jobs release the deploy lock. Run dep deploy production from local machine or CI.

Deployer adds zero extra servers. Blue-green typically costs Rs 8,000–15,000/month (~USD 60–110) extra VPS budget for duplicate infrastructure.

Your Ubuntu 22/24 VPS needs a dedicated deploy user with SSH key authentication and passwordless sudo limited to service reload commands. Install PHP 8.3 or 8.5 FPM, Composer 2.10, and Git on the server. Point the vhost document root at {{deploy_path}}/current/public, not a hardcoded release path. Add your CI runner's public key to ~deploy/.ssh/authorized_keys. Open port 22 from the CI runner IP only—never expose SSH to the entire internet. Fix ownership so deploy owns releases and shared storage/ is group-writable by www-data.

Anything that must survive across releases belongs in shared/, not inside a release folder. Laravel's default recipe shares .env and storage/. Extend shared_dirs for storage/app/public, bootstrap/cache, and shared_files for OAuth keys when needed. Symfony 8.1 typically shares .env.local.php, var/log, and public/uploads. WordPress 7.1 and WooCommerce 11.1 share wp-content/uploads and sometimes wp-content/cache. Place .env once in shared/.env on the server—never commit it to Git. Deployer symlinks shared paths into each release automatically.

Code deploys are atomic; database schema changes are not. Running a breaking migration before new code is live crashes the old release. The safe pattern is backward-compatible migrations first: add nullable columns, deploy code that reads both old and new columns, backfill data separately, then deploy code using only the new column, and drop the old column in a later release. Disable automatic migration in deploy.php when a DBA must review first. Rollback with dep rollback does not reverse migrations—you need a forward migration or backup restore.

PHP opcache stores compiled bytecode in worker memory. After Deployer atomically swaps current, FPM workers may still serve old cached files for minutes, producing mixed old and new behaviour. Add a php-fpm:reload task after deploy:symlink calling sudo systemctl reload php8.3-fpm. Grant deploy passwordless reload via sudoers. With opcache.validate_timestamps=0 in production—which is correct for Deployer deploys because you always reload FPM—workers never check file mtimes. Nginx only proxies to FPM, so the FPM reload is the critical step, not an Nginx restart.

Yes. A symlink swap alone does not restart queue workers. Horizon and Laravel 13 queue workers cache compiled job classes in memory. Missing a restart produces random class-not-found errors because only some workers hold stale bytecode. Add after('deploy:symlink', 'artisan:queue:restart') to deploy.php. This applies whenever your app processes background jobs. Scheduled tasks via cron are separate—they must target /var/www/my-app/current, not a hardcoded releases/ path, or scheduled tasks silently stop after keep_releases prunes old folders.

Never use FTP or rsync overwrite on production—they modify files while Apache serves them, creating high downtime risk and slow rollback from backups. Git pull on the live tree carries medium risk and suits staging only. Deployer builds the full release before touching the live tree, achieving zero downtime with seconds-fast rollback via dep rollback. For most PHP agencies and SMB clients, including Nepal-based projects, Deployer hits the sweet spot between simplicity and reliability without doubling infrastructure like blue-green deployment.

Define test and deploy stages. The test job runs composer install and phpunit on php:8.3-cli. The deploy job on main installs rsync, openssh-client, and Deployer globally, loads SSH_PRIVATE_KEY from a masked CI variable, adds the server to known_hosts, then runs dep deploy production -vvv. Build frontend assets in CI with Node 26 LTS when the production VPS has no Node—commit Vite 8.x output or pass build artefacts. Never embed SSH keys in .gitlab-ci.yml. Manual dep deploy from a laptop works until someone forgets which branch was live.

Deployer keeps the last N releases—default 5, configured via keep_releases. Roll back in seconds with dep deploy rollback production, which repoints current to the previous release and reloads services. This does not reverse database migrations; that requires a forward migration or restore from backup. If a killed CI job leaves a deploy lock stuck, run dep deploy:unlock production. The after('deploy:failed', 'deploy:unlock') hook prevents most lock issues, but monitor CI job timeouts on long deploys.

Stale cron paths pointing at releases/20260801_120000 instead of current silently stop Laravel scheduled tasks after old releases are pruned. Deploy locks left open by killed CI jobs block subsequent deploys until you run dep deploy:unlock. Config and route caches baked into a release can reference old paths—clear them with artisan:config:cache, route:cache, and view:cache after symlink swap. Incorrect file ownership between deploy and www-data breaks shared storage writes. Redis-backed sessions and cache may need attention per your caching setup.

Yes, on critical apps. Add a deploy:health task before deploy:symlink that verifies the release boots— for example running php artisan --version inside release_path and throwing if empty. HTTP health checks against a temporary URL or internal /health endpoint catch missing .env keys and broken Composer autoloaders before traffic hits the new release. GitLab CI should run tests in an earlier stage; Deployer health checks are the last gate before the atomic symlink swap makes new code live to all incoming requests.

Deployer 7 ships recipes for Laravel, Symfony, Yii, and WordPress. Symfony 8.1 on PHP 8.4.1+ uses its own shared path layout. WordPress 7.1 and WooCommerce 11.1 need wp-content/uploads shared. You can write a custom deploy.php for legacy CodeIgniter or plain PHP—the release model stays the same regardless of framework. I've shipped this pattern with Deployer 7 and GitLab CI across Laravel legal-tech portals and sister sites on shared Ubuntu EC2 infrastructure, keeping shared-directory layouts consistent to reduce midnight debugging.

Deployer achieves zero downtime by building a full release in a new folder, then atomically swapping one current symlink—one server, seconds-fast rollback via dep rollback, medium complexity. Blue-green runs two full stacks and switches traffic instantly, but needs double infrastructure, higher complexity, and suits high-traffic SaaS. Docker and Kubernetes offer rolling deploys with revision rollback for container-native teams. For typical PHP production workloads at agencies and SMB clients, Deployer balances zero downtime with practical rollback speed without the Rs 8,000–15,000/month (~USD 60–110) extra VPS cost blue-green demands.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: