Skip to content

Instantly share code, notes, and snippets.

@rnixik
Created February 19, 2020 08:42
Show Gist options
  • Save rnixik/f8f9ecd7d402f46ffea0ea14420ffa82 to your computer and use it in GitHub Desktop.
Save rnixik/f8f9ecd7d402f46ffea0ea14420ffa82 to your computer and use it in GitHub Desktop.
Deployment script for Laravel applications with docker-compose
<?php
/*
* Deployment script for Laravel applications with docker-compose.
*
* Features:
* * Zero-downtime deployment with jwilder/nginx-proxy
* * Independent several instances of the same project on one host
* * Configurable shared .env file
* * Ready for CI / CD
* * Shared logs and uploads between releases
* * Same app container for cli operations and web
*
* Dependencies:
* * docker
* * docker-compose
* * Running container with jwilder/nginx-proxy
*
* Details:
* We need ability to set working_dir to project root dir.
* To be able to do without source code inside image we can
* mount source dir to container and set it as working_dir.
* Usually, it is `/app/current`.
*
* To have one shared dir for logs (or uploads) we need use symlinks
* or mount points. We can't use symlinks easily because paths inside
* container differ from host system. We use mounts:
* ```
* volumes:
* - ./:/app/current/
* - ./../../shared/storage/app:/app/current/storage/app
* - ./../../shared/storage/logs:/app/current/storage/logs
* ```
*
* To solve problem with downtime we need keep two working instances
* of docker-compose at the same time. To distinct them for commands up and down
* we can use env `COMPOSE_PROJECT_NAME` or `-p` option. But we should keep
* database instance the same between releases. To achieve this we can
* use separate docker-compose.yml file with shared services and
* set other COMPOSE_PROJECT_NAME for them.
*
* To run cli operations like DB migration we need running container with new
* version of code but this container should not be accessible from web during
* migration and other preparation. That is the reason for command
* `docker_compose:up_no_web` - we declare empty `VIRTUAL_HOST` for nginx-proxy.
*
* Environment variables DEPLOY_HOSTNAME (alpha) and DEPLOY_INDEX (1) are used
* for docker-compose.yml files to mount hostname-specific folders
* and bind hostname-specific ports:
* ```
* - ~/.docker/volumes/${DEPLOY_HOSTNAME}/db:/data:cached
* - 127.0.0.1:5432${DEPLOY_INDEX}:5432
* ```
*
* Script prepares file 'export_env.sh' with necessary environment variables
* to work with docker-compose and project's scripts in release directory:
* `. export_env.sh && docker-compose up -d`
*/
namespace Deployer;
require_once 'recipe/common.php';
use Deployer\Exception\Exception;
function isProduction()
{
return 'production' === input()->getArgument('stage');
}
// Laravel shared file. These files must exist in shared dir before deployment
set('shared_files_to_copy', [
'.env',
'auth.json',
]);
// List of directories to mount in docker-compose
set('shared_writable_dirs_to_mount', [
'storage/app',
'storage/logs',
]);
// Laravel writable dirs (built-in 'writable_dirs' does not set 0777)
set('force_writable_dirs', [
'bootstrap/cache',
'storage/framework',
'storage/framework/cache',
'storage/framework/sessions',
'storage/framework/views',
]);
// Artisan commands
function artisan($cmd) {
return function () use ($cmd) {
// Runs docker-compose exec -T
$output = run("cd {{release_path}} && ./docker-exec-no-tty php artisan $cmd");
writeln("<info>{$output}</info>");
};
}
task('artisan:migrate', artisan('migrate --force'))
->desc('Migrate database');
task('artisan:config:cache', artisan('config:cache'))
->desc('Cache actual application settings');
task('artisan:view:cache', artisan('view:cache'))
->desc('Cache actual application view file');
task('artisan:optimize', artisan('optimize'))
->desc('Optimize');
// Docker-compose commands
task('docker_compose:network', function () {
$projectName = get('env')['DEPLOY_HOSTNAME'] ;
run("docker network create $projectName || true");
})->desc('Create external docker network');
task('docker_compose:set_name', function () {
$composeProjectName = get('env')['DEPLOY_HOSTNAME'] . '_' . get('release_name');
set('env', get('env') + ['COMPOSE_PROJECT_NAME' => $composeProjectName]);
writeln("<info>Current docker-compose project name: {$composeProjectName}</info>");
})->desc('Set docker-compose project name for new release');
task('docker_compose:up_deps', function () {
$stage = get('stage');
// Shared project name does not depend on release name
$depsProjectName = get('env')['DEPLOY_HOSTNAME'];
$output = run("cd {{release_path}} && if [ -f docker-compose.$stage.deps.yml ]; then docker-compose -f docker-compose.$stage.deps.yml -p $depsProjectName up -d; fi");
writeln("<info>{$output}</info>");
})->desc('Bringing up dependency services');
task('docker_compose:copy', function () {
$stage = get('stage');
run("cp -f {{release_path}}/docker-compose.$stage.yml {{release_path}}/docker-compose.yml");
})->desc('Copying docker-compose.yml file');
task('docker_compose:up_no_web', function () {
$output = run("cd {{release_path}} && export VIRTUAL_HOST='' && docker-compose up -d --build");
writeln("<info>{$output}</info>");
})->desc('Bringing up main service without virtual host');
task('docker_compose:up_web', function () {
$virtualHost = get('virtual_host');
$output = run("cd {{release_path}} && export VIRTUAL_HOST='$virtualHost' && docker-compose up -d");
writeln("<info>{$output}</info>");
})->desc('Bringing up main web service');
task('docker_compose:down_prev', function () {
$composePrevProjectName = get('env')['DEPLOY_HOSTNAME'] . '_' . (((int) get('release_name')) - 1);
$output = run("cd {{ deploy_path }}/current && docker-compose -p $composePrevProjectName down || true");
writeln("<info>{$output}</info>");
})->desc('Stop previous version of main service');
// Other commands
task('composer:install', function () {
$output = run('cd {{release_path}} && ./docker-exec-no-tty "composer install --no-interaction --no-suggest --no-dev"');
writeln('<info>'.$output.'</info>');
})->desc('Install composer dependencies');
task('files:permissions', function () {
foreach (get('force_writable_dirs') as $dir) {
run("chmod 0777 -R {{release_path}}/$dir || true");
}
})->desc('Set permissions to project writable dirs');
task('files:make_export_env', function () {
$exportCommand = 'export';
foreach (get('env') as $name => $value) {
$exportCommand .= " $name=\"$value\"";
}
run("echo '#!/bin/bash' > {{release_path}}/export_env.sh");
run("echo '$exportCommand' >> {{release_path}}/export_env.sh");
run("chmod +x {{release_path}}/export_env.sh");
})->desc('Make export_env.sh with env values');
task('shared:prepare_mounts', function () {
$sharedPath = "{{deploy_path}}/shared";
foreach (get('shared_writable_dirs_to_mount') as $dir) {
run("mkdir -p -m 0777 $sharedPath/$dir");
// Delete destination points in release dir
run("rm -rf {{release_path}}/$dir");
}
})->desc('Make writable directories to mount later');
task('shared:copy', function () {
// We copy because we cannot use easily symlinks inside volumes
$sharedPath = "{{deploy_path}}/shared";
foreach (get('shared_files_to_copy') as $file) {
$dirname = dirname(parse($file));
if (!test("[ -f $sharedPath/$file ]")) {
throw new Exception("Shared file to copy does not exist: $sharedPath/$file");
}
// Create dir of shared file if not existing
if (!test("[ -d {{release_path}}/{$dirname} ]")) {
run("mkdir -p {{release_path}}/{$dirname}");
}
// Copy shared file to release path
run("cp -rvf $sharedPath/$file {{release_path}}/$file");
}
})->desc('Copy shared files to release path');
task('shared:public_disk', function () {
// Remove from source.
run('if [ -d $(echo {{release_path}}/public/storage) ]; then rm -rf {{release_path}}/public/storage; fi');
// Create shared dir if it does not exist.
run('mkdir -p {{deploy_path}}/shared/storage/app/public');
// Symlink shared dir to release dir
run('cd {{release_path}} && {{bin/symlink}} ./storage/app/public ./public/storage');
})->desc('Make symlink for public disk');
task('deploy', [
'deploy:info',
'deploy:prepare',
'deploy:lock',
'deploy:release',
'deploy:update_code',
'deploy:shared',
'shared:prepare_mounts',
'shared:copy',
'files:permissions',
'docker_compose:set_name',
'files:make_export_env',
'docker_compose:network',
'docker_compose:up_deps',
'docker_compose:copy',
'docker_compose:up_no_web',
'composer:install',
'artisan:migrate',
'shared:public_disk',
'artisan:view:cache',
'artisan:config:cache',
'artisan:optimize',
'docker_compose:up_web',
'docker_compose:down_prev',
'deploy:symlink',
'deploy:unlock',
'cleanup',
])->desc('Deploying backend application');
after('deploy', 'success');
host('alpha')
->user('root')
->hostname(/* Your staging host */)
->stage('staging')
->set('deploy_path', '/app-alpha')
->set('virtual_host', 'app-alpha.your-domain.com')
->set('env', [
'APP_ENV' => 'staging',
'ENV' => 'staging',
'DEPLOY_HOSTNAME' => 'alpha',
'DEPLOY_INDEX' => 1,
]);
set('branch', function () {
// This is change from base 'branch' command
if (isProduction()) {
return 'master';
}
try {
$branch = runLocally('git rev-parse --abbrev-ref HEAD');
} catch (\Throwable $exception) {
$branch = null;
}
if ('HEAD' === $branch) {
$branch = null; // Travis-CI fix
}
if (input()->hasOption('branch') && !empty(input()->getOption('branch'))) {
$branch = input()->getOption('branch');
}
return $branch;
});
set('repository', /* Address of your git repository here */);
set('allow_anonymous_stats', false);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment