Zero downtime local build Laravel 5 deploys with Deployer

Moving beyond Envoy

Laravel Envoy (version 5.4) was a nice leap forward from Rocketeer, which ported Capistrano’s zero downtime deploy to PHP. Laravel’s response, Envoy, is a “do it yourself” minimalist tool compared to Rocketeer’s pre-built recipes that were almost plug-and-play.

Working with one or two projects, using a service like Envoyer to deploy sites isn’t a problem. But when the number of projects hits over 100, the cost becomes a burden. Using a per project deployment is the only cost effective approach.

Requirements

  • Zero downtime deploy with ability to rollback
  • Build on local (or CI machine) and rsync to server(s)
  • Passing tests before deploy
  • Deploy to both development and production environments
  • Minimal overhead per project

Directory structure

├── current -> releases/9
├── releases
│ ├── 7
│ │ ├── ...
│ ├── 8
│ │ ├── ...
│ └── 9
│ ├── app
│ ├── artisan
│ ├── bootstrap
│ ├── composer.json
│ ├── composer.lock
│ ├── config
│ ├── database
│ ├── package.json
│ ├── phpunit.xml
│ ├── public
│ ├── readme.md
│ ├── resources
│ ├── routes
│ ├── server.php
│ ├── storage -> ../../shared/storage
│ ├── tests
│ ├── vendor
│ ├── .env -> ../../shared/.env
│ └── webpack.mix.js
└── shared
└── .env
└── storage
├── app
├── framework
└── logs

Existing Envoy deploy

A single file, Envoy.blade.php, 300 lines (or more) with literal commands that rely on Blade to store and output variables. A mix of PHP, Blade and SSH feels like an uncomfortable mesh of concerns.

@macro('deploy')
show_env_local
show_env_remote
init_basedir_local
init_basedir_remote
updaterepo_localsrc
depsinstall_localsrc
packrelease_localsrc
rcpreleasepack_to_remote
extractreleasepack_on_remote
syncshareddata_remotesrc
baseenvlink_remoterelease
prepare_remoterelease
link_newrelease_on_remote
cleanup_oldreleases_on_remote
clean_localsrc
@endmacro

A single task is a shell script written in Blade:

@task('link_newrelease_on_remote', ['on' => $remote_server])
echo "Deploy new release link";
cd {{ $app_base }};
[ -d {{ $prev_dir }} ] && unlink {{ $prev_dir }};
[ -d {{ $app_dir }} ] && mv {{ $app_dir }} {{ $prev_dir }};
ln -nfs {{ $release_dir }}/{{ $release }} {{ $app_dir }} && chgrp -h {{$serviceowner}} {{ $app_dir }};
echo "Deployment ({{ $release }}) symbolic link created";
@endtask

The inability to extract these self contained tasks into PSR loaded files is more irritating than it should be.

Deployer to the rescue

Example deployer.org deploy

Deployer (version 4.3) takes the modern PHP approach to deployment with a PSR loaded package, object-oriented, self contained and reusable throughout any deployment strategy.

It is framework independent and easily extendable.

Although a ‘do it yourself” option is available, it comes packaged with starter scripts for Laravel, Symfony, Yii, Zend, CakePHP, CodeIgniter, and Drupal. Additionally, a host of “Recipes” are maintained independently for specific deploy tasks.

Expanding on the base Laravel deploy

The base Laravel deploy is pretty basic and would work perfect for most use cases, but clones and builds on the remote servers. Let’s combine it with the Local, RSYNC and NPM builds to shift all build requirements to the continuous integration (CI) server. This frees up the software needed on the production servers and ensures tests are passing before deploy.

Install Deployer and recipes

composer require deployer/deployer --dev
composer require deployer/recipes --dev

Initialize

dep init

Resulting /deploy.php script

<?php
namespace Deployer;
require 'recipe/laravel.php';

// Configuration
set('ssh_type', 'native');
set('ssh_multiplexing', true);
set('repository', 'git@domain.com:username/repository.git');

add('shared_files', []);
add('shared_dirs', []);
add('writable_dirs', []);

// Servers
server('production', 'domain.com')
->user('username')
->identityFile()
->set('deploy_path', '/var/www/domain.com')
->pty(true);


// Tasks
desc('Restart PHP-FPM service');
task('php-fpm:restart', function () {
// The user must have rights for restart service
// /etc/sudoers: username ALL=NOPASSWD:/bin/systemctl restart php-fpm.service
run('sudo systemctl restart php-fpm.service');
});
after('deploy:symlink', 'php-fpm:restart');

// [Optional] if deploy fails automatically unlock.
after('deploy:failed', 'deploy:unlock');

// Migrate database before symlink new release.
before('deploy:symlink', 'artisan:migrate');

Adding local clone and rsync to server

<?php
namespace Deployer;
require 'recipe/laravel.php';
require 'vendor/deployer/recipes/local.php';
require 'vendor/deployer/recipes/rsync.php';
// Configuration
set('ssh_type', 'native');
set('ssh_multiplexing', true);
set('writable_mode', 'chmod');
set('default_stage', 'dev');
set('local_deploy_path', '/tmp/deployer');
...
// RSYNC files from /tmp/deployer
set('rsync_src', function() {
$local_src = get('local_release_path');
if(is_callable($local_src)){
$local_src = $local_src();
}
return $local_src;
});
// Servers
server('production', 'domain.com')
->user('username')
->identityFile()
->set('branch', 'master')
->set('deploy_path', '/var/www/domain.com')
->stage('production');

server('dev', 'dev.domain.com')
->user('username')
->identityFile()
->set('branch', 'develop')
->set('deploy_path', '/var/www/domain.com')
->stage(['dev', 'production']);
// Tasks
task('deploy', [
'local:prepare', // Create dirs locally
'local:release', // Release number locally
'local:update_code', // git clone locally
'local:vendors', // composer install locally
'local:symlink', // Symlink /current locally
'deploy:prepare', // Create dirs on server
'deploy:lock', // Lock deploys on server
'deploy:release', // Release number on server
'rsync', // Send files to server
'deploy:writable', // Ensure paths are writable on server
'deploy:shared', // Shared and .env linking on server
'artisan:view:clear', // Optimze on server
'artisan:cache:clear', // Optimze on server
'artisan:config:cache', // Optimze on server
'artisan:optimize', // Optimze on server
'artisan:migrate', // Migrate DB on server
'deploy:symlink', // Symlink /current on server
'deploy:unlock', // Unlock deploys on server
'cleanup', // Cleanup old releases on server
'local:cleanup' // Cleanup old releases locally
])->desc('Deploy your project');
...

Adding in NPM install and Laravel Mix build

<?php
namespace Deployer;
require 'recipe/laravel.php';
require 'vendor/deployer/recipes/local.php';
require 'vendor/deployer/recipes/rsync.php';
require 'vendor/deployer/recipes/npm.php';
...
add('rsync', [
'exclude' => [
'.git',
'deploy.php',
'node_modules',
],
]);
...
// Build assets locally
task('npm:local:build', function () {
runLocally("cd {{local_release_path}} && {{local/bin/npm}} run production");
});
// Tasks
task('deploy', [
'local:prepare', // Create dirs locally
'local:release', // Release number locally
'local:update_code', // git clone locally
'local:vendors', // composer install locally
'npm:local:install', // npm install locally
'npm:local:build', // Build locally

'local:symlink', // Symlink /current locally
...
'cleanup', // Cleanup old releases on server
'local:cleanup' // Cleanup old releases locally
])->desc('Deploy your project');
...

Run phpunit tests before deploy

<?php
...
// Run tests
task('local:phpunit', function () {
runLocally("cd {{local_release_path}} && phpunit");
});
// Tasks
task('deploy', [
'local:prepare', // Create dirs locally
...
'local:update_code', // git clone locally
'local:vendors', // composer install locally
'local:phpunit', // phpunit tests locally
'npm:local:install', // npm install locally
'npm:local:build', // Build locally
'rsync', // Send files to server
...

Putting it all together, the final script

<?php
namespace Deployer;
require 'recipe/laravel.php';
require 'vendor/deployer/recipes/local.php';
require 'vendor/deployer/recipes/rsync.php';
require 'vendor/deployer/recipes/npm.php';
// Configuration
set('ssh_type', 'native');
set('ssh_multiplexing', true);
set('writable_mode', 'chmod');
set('default_stage', 'dev');
set('repository', 'git@domain.com:username/repository.git');
add('shared_files', []);
add('shared_dirs', []);
add('writable_dirs', []);
add('rsync', [
'exclude' => [
'.git',
'deploy.php',
'node_modules',
],
]);
// RSYNC files from /tmp/deployer instead of vendor/deployer/recipes/
set('rsync_src', function() {
$local_src = get('local_release_path');
if(is_callable($local_src)){
$local_src = $local_src();
}
return $local_src;
});
// Servers
server('production', 'domain.com')
->user('username')
->identityFile()
->set('branch', 'master')
->set('deploy_path', '/var/www/domain.com')
->stage('production');

server('dev', 'dev.domain.com')
->user('username')
->identityFile()
->set('branch', 'develop')
->set('deploy_path', '/var/www/domain.com')
->stage(['dev', 'production']);
// Build assets locally
task('npm:local:build', function () {
runLocally("cd {{local_release_path}} && {{local/bin/npm}} run production");
});
// Run tests
task('local:phpunit', function () {
runLocally("cd {{local_release_path}} && {{phpunit}}");
});
// Tasks
task('deploy', [
'local:prepare', // Create dirs locally
'local:release', // Release number locally
'local:update_code', // git clone locally
'local:vendors', // composer install locally
'local:phpunit', // phpunit tests locally
'npm:local:install', // npm install locally
'npm:local:build', // Build locally
'local:symlink', // Symlink /current locally
'deploy:prepare', // Create dirs on server
'deploy:lock', // Lock deploys on server
'deploy:release', // Release number on server
'rsync', // Send files to server
'deploy:writable', // Ensure paths are writable on server
'deploy:shared', // Shared and .env linking on server
'artisan:view:clear', // Optimze on server
'artisan:cache:clear', // Optimze on server
'artisan:config:cache', // Optimze on server
'artisan:optimize', // Optimze on server
'artisan:migrate', // Migrate DB on server
'deploy:symlink', // Symlink /current on server
'deploy:unlock', // Unlock deploys on server
'cleanup', // Cleanup old releases on server
'local:cleanup' // Cleanup old releases locally
])->desc('Deploy your project');
// [Optional] if deploy fails automatically unlock.
after('deploy:failed', 'deploy:unlock');

View full deploy.php as gist

Terminal output of deploy

Build and deploy to development

dep deploy

Build and deploy to both development and production

dep deploy production

Add Verbosity

dep deploy production -vv

Rollback to previous release

dep rollback

Result

The resulting file is ~90 lines of mostly configuration and two custom functions. It is clear what the script does and gets the benefit of updating as the package evolves.

Although every environment is different, with this starter script it is possible to add more build steps, more test requirements and additional servers.