
September 09, 2026
13 min read
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.
current symlink swap. Traffic keeps hitting the old release until the new one passes health checks, then PHP-FPM reloads so opcache picks up fresh bytecode.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.
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. - Hooks —
before,after, andfailedevents 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:
- Create user
deploywith passwordless sudo limited to service reload commands. - Add your CI runner's public key to
~deploy/.ssh/authorized_keys. - Ensure PHP 8.3 or 8.5 FPM, Composer 2.10, and Git are installed on the server.
- Point the vhost document root at
{{deploy_path}}/current/public. - 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.
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:
- Add new columns as nullable before deploy.
- Deploy code that reads/writes both old and new columns.
- Backfill data in a separate job or migration.
- Deploy code that uses only the new column.
- 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.
How Do You Handle Opcache and PHP-FPM After a Symlink Swap?
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
| Strategy | Downtime | Rollback Speed | Complexity | Best For |
|---|---|---|---|---|
| FTP / rsync overwrite | High risk | Slow (restore backup) | Low | Never on production |
| Git pull on live tree | Medium risk | Git revert + manual | Low | Staging only |
| Deployer symlink releases | None | Seconds (dep rollback) | Medium | Laravel, Symfony, WP |
| Blue-green (two full stacks) | None | Instant traffic switch | High | High-traffic SaaS |
| Docker/Kubernetes rolling | None | Revision rollback | High | Container-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.
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.
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
currentsymlink 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=0in production. - Run backward-compatible migrations before deploy; restart queue workers after swap.
- Wire
dep deployinto GitLab CI with SSH keys as masked variables, not committed secrets. - Keep five releases and practice
dep rollback productionbefore 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
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.

