Symfony Deployment: Zero Downtime with Deployer PHP
AI generated
SF
{ }
Symfony · Deployer PHP · DevOps · CI/CD
Symfony Deployment:
Zero Downtime with Deployer PHP

Every Symfony deployment that briefly puts the application into an inconsistent state is an avoidable risk. Deployer PHP solves this with atomic symlinks: the new release is fully built and ready before a single request hits it, the symlink swap takes milliseconds, and it makes a rollback trivial.

16 min read Deployer · Atomic Symlinks · Rollback · Migrations · Multi-Stage Symfony 7.x · Deployer 7.x · PHP 8.3+

1. Why classic deployments cause downtime

A classic Symfony deployment without a zero-downtime strategy follows a dangerous pattern: the new code is copied into the current directory or pulled in via git pull, then Composer dependencies are installed, assets are built, and the cache is cleared. During this sequence, which depending on the project takes anywhere from 30 seconds to several minutes, the application is in a partially updated state. Requests hit new PHP code that is still running against the old Composer autoloader, or old templates while the new cache is still being built. The result is 500 errors, inconsistent database states from unfinished migrations, and in the worst case, data loss.

The problem is structural, not operational. It is not that deployments are executed incorrectly, but that the deployment process has no concept of an atomic transition between two states. Zero-downtime deployment with Deployer PHP solves this through parallelism instead of sequencing: the new release is built completely alongside the running system. The switch does not happen by replacing files, but by repointing a single symlink at the operating system level, an operation the operating system executes atomically. No request ever lands on a half-finished state.

2. Installing and configuring Deployer PHP

Deployer PHP is installed either as a global PHAR tool or as a Composer dev dependency. For Symfony projects the recommendation is the Composer dev dependency, because it locks the version in the project and keeps it consistently available in the CI/CD workflow. The Symfony recipe is available as its own package and contains predefined tasks for cache warmup, asset installation, and running migrations. The configuration file deploy.php in the project root is the single entry point, where hosts, paths, shared directories, and the task flow are defined.

The directory structure on the target server after the first Deployer PHP deployment shows the pattern clearly: a releases/ directory contains numbered release folders, shared/ contains files shared across releases such as .env.local and the var/log folder, and current is the symlink pointing to the active release. The web server points to current/public, not to a specific release directory. That is the foundation for the atomic release switch and for rollback capability without any manual intervention.


<?php
// deploy.php: Root configuration file for Deployer PHP
namespace Deployer;

require 'recipe/symfony.php';

// Project name used in log output and notifications
set('application', 'my-symfony-app');

// Git repository, SSH access required on the server
set('repository', 'git@github.com:vendor/my-symfony-app.git');

// Keep last 5 releases on server for rollback capability
set('keep_releases', 5);

// Shared files/dirs between releases, survive across deployments
set('shared_files', ['.env.local']);
set('shared_dirs', ['var/log', 'var/sessions', 'public/uploads']);

// Writable dirs: Deployer sets correct permissions automatically
set('writable_dirs', ['var', 'public/uploads']);

// Symfony-specific: build assets before deployment (locally)
set('bin/console', '{{release_path}}/bin/console');

// Target server configuration
host('production')
    ->set('hostname', 'your-server.example.com')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/var/www/my-symfony-app')
    ->set('branch', 'main');

// Custom task: warm up cache after deployment
after('deploy:vendors', 'deploy:cache:warmup');

3. Atomic symlinks: the heart of zero-downtime deployment

The atomic symlink is the technical core of every zero-downtime deployment. On Unix systems, replacing a symlink via ln -sfn is an atomic operation: from the perspective of the operating system, and therefore from the perspective of the web server, there is never a moment where the symlink points to no valid target. The new release has been fully built up to that point: Composer dependencies installed, assets compiled, cache warmed, migrations executed. The moment PHP-FPM sees the new release is exactly the moment of the symlink swap.

Deployer PHP applies this pattern consistently: the deploy:symlink task performs the atomic swap and is the only task in the deployment flow that affects the running service. All previous tasks operate in the new release directory without touching the running system. After the symlink swap, PHP-FPM processes still serving the old release do not necessarily need to be aborted; they finish running against the old release directory, which still exists on disk. Only once no processes are using the old directory anymore can it be deleted during the next deployment cleanup.

4. Configuring shared dirs and shared files

In a Deployer PHP setup, not all files are release-specific. Configuration files such as .env.local, which contain server-specific database passwords and API keys, do not belong in the repository or in the release folder; they belong in the shared/ directory and are linked from release to release. The same applies to directories such as var/log, var/sessions, and upload directories: logs should accumulate across releases, sessions should not expire on deployment, and uploads should be preserved.

Deployer PHP creates the shared directories on the first deployment and sets up symlinks from the release directory into the shared directory. Every new release finds the same symlinks and sees the same shared data. That means: when a rollback is executed, the shared symlinks continue to point to the same shared/ directory. Logs and uploads are not lost, and the database state stays consistent. The zero-downtime deployment concept is thereby complete: the code switches atomically, the persistent data stays stable.


<?php
// deploy.php: Full Symfony task flow with migrations and asset compilation
namespace Deployer;

require 'recipe/symfony.php';

set('application', 'symfony-app');
set('repository', 'git@github.com:vendor/symfony-app.git');
set('keep_releases', 5);

// Shared between all releases, never deleted
set('shared_files', ['.env.local', 'config/jwt/private.pem', 'config/jwt/public.pem']);
set('shared_dirs', ['var/log', 'var/sessions', 'public/uploads', 'public/media']);

// Directories that need write permissions for Symfony
set('writable_dirs', ['var', 'public/uploads', 'public/media']);
set('writable_mode', 'acl'); // use 'chmod' if ACL not available

// Custom deployment task sequence
task('deploy', [
    'deploy:info',          // Print deploy info (host, branch, commit)
    'deploy:setup',         // Create release directory structure
    'deploy:lock',          // Prevent concurrent deployments
    'deploy:release',       // Create new release directory
    'deploy:update_code',   // Git clone/checkout into release dir
    'deploy:shared',        // Create shared symlinks (.env.local, var/log …)
    'deploy:vendors',       // composer install --no-dev --optimize-autoloader
    'deploy:assets',        // php bin/console assets:install
    'deploy:cache:warmup',  // php bin/console cache:warmup
    'database:migrate',     // php bin/console doctrine:migrations:migrate --no-interaction
    'deploy:symlink',       // ATOMIC: switch current symlink to new release
    'deploy:unlock',        // Remove deployment lock
    'deploy:cleanup',       // Delete old releases (keep_releases)
    'deploy:success',       // Print success message
]);

// Rollback on failure: keeps deployment lock clean
after('deploy:failed', 'deploy:unlock');

5. Automating database migrations safely

Database migrations are the trickiest part of zero-downtime deployment with Symfony. The problem: migrations run before the symlink is switched, but the old code is still running. If a migration renames a column or adds a NOT-NULL column without a default value, every insert from the old code fails until the new code is active. The solution is a migration strategy that treats backward compatibility as a design principle: every migration must work with the old and the new code at the same time.

Backward-compatible migrations follow a three-phase pattern: phase 1 adds new columns or tables (with a default value or as nullable), which the old code ignores. Phase 2 deploys the new code, which populates the new columns. Phase 3 removes old columns or NOT-NULL constraints in a later deployment. For Deployer PHP, you implement a custom migration task that runs before the symlink swap and aborts automatically on failure. The rollback task ensures that an aborted deployment never leaves an inconsistent database state, because the symlink is never switched when a migration fails.

6. Rollback strategy: back to the last release in seconds

The biggest operational advantage of the Deployer PHP approach lies in the rollback: dep rollback production sets the current symlink back to the second-to-last release. Since all release directories are fully built and sitting on disk, this is exactly the same atomic symlink operation as during deployment; no files are copied, no code is rebuilt. The rollback takes about as long as it takes for the deployment of the last successful release to display in the terminal: seconds, not minutes.

The rollback window is determined by the keep_releases configuration. With five retained releases, you can roll back to any of the last four previous states. In practice that is enough: if a problem surfaces after more than five deployments, a hotfix is the better strategy than rolling back to code from far in the past. Important: database migrations are not automatically undone. A code rollback without a corresponding migration rollback can cause errors if the old code encounters new database columns it does not know about. Backward-compatible migrations from the previous section prevent exactly this scenario.


<?php
// deploy.php: Rollback-safe migration task with health check
namespace Deployer;

require 'recipe/symfony.php';

// Custom migration task: runs before symlink, aborts on failure
task('database:migrate', function () {
    // Run migrations in --dry-run first to detect issues before applying
    run('{{bin/php}} {{release_path}}/bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration');
})->desc('Run Doctrine migrations before symlink swap');

// Health check after deployment: abort and rollback if app responds with error
task('deploy:health_check', function () {
    $host = get('app_url'); // e.g. https://my-symfony-app.com
    $response = run("curl -s -o /dev/null -w '%{http_code}' {$host}/health");

    if ($response !== '200') {
        invoke('rollback');
        throw new \RuntimeException("Health check failed (HTTP {$response}). Rolled back automatically.");
    }
})->desc('HTTP health check after symlink swap');

// Multi-server: rolling deployment across app cluster
host('app-01')->set('hostname', 'app01.example.com')->set('deploy_path', '/var/www/app');
host('app-02')->set('hostname', 'app02.example.com')->set('deploy_path', '/var/www/app');

// Deployment sequence: migrate once on app-01, then deploy to all
task('deploy:cluster', [
    'deploy:setup',
    'deploy:release',
    'deploy:update_code',
    'deploy:shared',
    'deploy:vendors',
    'deploy:cache:warmup',
    'database:migrate',   // Run once, not per host
    'deploy:symlink',
    'deploy:cleanup',
]);

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

7. Multi-stage configuration: staging and production

In professional Symfony projects there are at least two environments: staging for internal testing and integration checks, production for live operation. Deployer PHP supports multiple hosts with different configurations within the same deploy.php. Each host gets its own path, its own branch, and its own configuration. With dep deploy staging you deploy to staging, with dep deploy production to production, using the same configuration file, different hosts and branches.

A proven convention in the zero-downtime deployment setup: staging always deploys from the develop branch, production always deploys from the main branch. Both environments run on identical Deployer tasks, but staging can enable additional debug tasks that are disabled in production. The staging deployment has a shorter keep_releases window because rollbacks are needed less often there. The shared directories on staging are populated with test data and are never synchronized with production, which is explicitly part of the staging configuration.

8. Integrating Deployer into GitHub Actions and GitLab CI

Deployer PHP integrates seamlessly into existing CI/CD pipelines. In GitHub Actions, Deployer PHP runs as its own job after the build and test step. The SSH key for the target server is stored as a GitHub secret and added to the pipeline via ssh-agent. The deployment only runs on the main branch; feature branches do not trigger a deploy. That keeps the pipeline fast, because tests run on all branches, but deployments only run on the stable branch.

An important optimization for the CI context: the Composer cache and the Node cache are stored between pipeline runs. Without a cache, Composer reinstalls all dependencies, which in large Symfony projects takes several minutes. With cache hits, dependency installation takes seconds. Deployer PHP supports building locally before deployment: assets and CSS can be built in CI and then transferred to the server via Rsync, instead of maintaining a full Node.js environment on the server. That cleanly separates the build environment from the production server.


# .github/workflows/deploy.yml: GitHub Actions deployment pipeline
name: Deploy Symfony to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none
      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist
      - name: Run tests
        run: php bin/phpunit --no-coverage

  deploy:
    needs: test  # Only deploy if tests pass
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - name: Install Deployer dependencies
        run: composer install --no-interaction --prefer-dist

      # Configure SSH key for server access
      - name: Setup SSH agent
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}

      # Add server to known_hosts to prevent interactive prompt
      - name: Add known host
        run: ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts

      # Run Deployer: dep is the CLI tool from vendor/bin
      - name: Deploy to Production
        run: vendor/bin/dep deploy production --no-interaction -vvv
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}

9. Deployer vs. Capistrano vs. manual deployment

Comparing different deployment approaches shows why Deployer PHP is the most pragmatic choice for Symfony projects. Capistrano, the original model for the atomic symlink approach, is written in Ruby and requires a Ruby runtime in the CI pipeline. For PHP teams that is an unnecessary technology break. Manual deployment via SSH without automation is error-prone and does not scale: every developer executes the steps slightly differently, documentation goes stale, and a rollback requires manual intervention with SSH access to the server.

Criterion Manual (SSH) Capistrano Deployer PHP
Zero Downtime No (replaces files) Yes (atomic symlink) Yes (atomic symlink)
Technology Bash Ruby PHP (no break)
Rollback Manual, error-prone cap production deploy:rollback dep rollback production
Symfony recipe Implement it yourself Capistrano-symfony gem recipe/symfony.php included
CI/CD integration Bash scripts required Ruby required in pipeline PHP pipeline, no extra setup

Choosing Deployer PHP for Symfony projects is above all a technology-coherence decision: the deployment configuration is PHP code that lives in the repository, is versioned, and is understandable to the entire team. New team members do not need to learn Ruby or Bash deployment scripts; the Deployer configuration is idiomatic PHP with clear task semantics. That reduces operational overhead and makes deployment a natural part of the Symfony project rather than an opaque black-box process.

Mironsoft

Symfony deployment, DevOps automation, and CI/CD pipelines

Want to build Symfony deployment without downtime?

We configure Deployer PHP for your Symfony project, from the atomic symlink strategy through safe migration automation to full GitHub Actions or GitLab CI integration.

Deployment setup

Configure Deployer PHP, set up shared dirs, and test the zero-downtime flow

CI/CD integration

Connect GitHub Actions or GitLab CI with Deployer, including SSH key management

Migration strategy

Introduce backward-compatible migrations and secure the rollback scenario

10. Summary

Zero-downtime deployment with Deployer PHP and Symfony is not a complicated setup, it is a structured process that turns deployment from a risky manual step into a reliable, repeatable operation. Atomic symlinks ensure that no request ever lands on half-finished code. Shared dirs preserve logs, sessions, and uploads across releases. The backward-compatible migration strategy decouples database changes from code deployment. Rollbacks take seconds, not hours.

The operational payoff is immediately noticeable: deployments can run at any time of day without a maintenance window, because no user ever sees downtime. The team can deploy more often, because the risk per deployment is minimal. And if a problem does occur, the rollback is a single command with a guaranteed, predictable outcome. Deployer PHP turns Symfony deployment into what it should be: a boring, reliable routine operation.

Symfony zero-downtime deployment: the key points at a glance

Atomic symlink

The core of zero-downtime deployment. The new release is fully ready before the symlink is switched, atomically, with no half-finished state.

Rollback in seconds

dep rollback production sets the symlink back to the previous release. No files to copy, no rebuild needed, seconds instead of minutes.

Backward-compatible migrations

Split migrations into three phases: add, deploy, clean up. That way old and new code work with the database at the same time.

CI/CD integration

Deployer PHP runs natively in PHP pipelines. GitHub Actions and GitLab CI only need an SSH key and vendor/bin/dep deploy production.

11. FAQ: Symfony Zero-Downtime Deployment with Deployer PHP

1What is zero-downtime deployment?
Activating a new version without users seeing an interruption. The atomic symlink swap ensures no request lands on half-finished code.
2How does the atomic symlink work?
Deployer fully builds the new release, then switches current via ln -sfn to the new directory. Atomic, no moment without a valid target.
3Performing a rollback?
dep rollback production sets current back to the previous release. No files to copy, no rebuild. Takes seconds.
4Database on rollback?
Migrations are not rolled back. Backward-compatible migrations in three phases prevent errors: add, deploy, clean up.
5What are shared dirs?
Directories shared across all releases: var/log, var/sessions, public/uploads. Deployer creates symlinks from the release into the shared/ directory.
6Multiple servers?
Define multiple hosts in deploy.php. Deploy in parallel or sequentially. Run migrations only once, symlink swap in parallel across all hosts.
7GitHub Actions integration?
SSH key as a secret, add webfactory/ssh-agent, run vendor/bin/dep deploy production. Deploy only on the main branch.
8How many releases to keep?
5 releases are ideal: 4 rollback points without using too much disk space. set('keep_releases', 5) in deploy.php.
9Deployer PHP vs. Capistrano?
Both use atomic symlinks. Capistrano needs Ruby in the pipeline. Deployer PHP runs natively in PHP pipelines with a ready-made Symfony recipe.
10Restart PHP-FPM after deployment?
A reload (not a restart) after the symlink swap is recommended. It invalidates OPcache, lets running requests finish with the old code, and serves new requests with the new code.