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
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');
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.