Created
February 19, 2020 08:42
-
-
Save rnixik/f8f9ecd7d402f46ffea0ea14420ffa82 to your computer and use it in GitHub Desktop.
Deployment script for Laravel applications with docker-compose
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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