How to do Zero Downtime deploys for a Laravel app with Envoy
Photo by Robert V. Ruggiero on Unsplash
TL;DR
- Configure SSH key into your VPS
- Copy the generated public key to GitLab or GitHub
- Access to your VPS from your computer using an SSH key
- Add the Envoy.blade.php file to your project
- Set initial project structure into your VPS
- Do a deploy from your computer
Requirement Install Laravel Envoy inside your computer
1. Configure Server with SSH for GitLab
For reference you can check the SSH GitLab docs.
In your server, generate a new SSH key with the name id_gitlab without password
ssh-keygen -t rsa -b 4096 -C "[email protected]" -f ~/.ssh/id_gitlab
And also add the following SSH configuration in ~/.ssh/config
Host gitlab.com
Hostname gitlab.com
PreferredAuthentications publickey
IdentityFile ~/.ssh/id_gitlab
And now add the GitLab identity
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_gitlab
2. Copy the generated public key to GitLab or GitHub
Copy the generated public key add it (id_gitlab.pub) to your SSH Keys in your GitLab account
You can now test the connection with GitLab inside your server running:
ssh -T [email protected]
3. Access to your VPS from your computer using an SSH key
Having your own ssh key from your computer to your server, ensure that you can connect to it without problems. Also, you can use a simple user and password configuration, but I don’t recommend it.
4. Add the Envoy.blade.php file to your project
For use zero downtime deploys, you need to structure project folder in this way:
* -- /path/project
|---------- current --> /path/project/releases/latestrelease
|---------- .env
|---------- releases
|---------- storage
Also, you need to update the virtual host configuration to point to /path/project/current/public
To achieve this structure, first of all, you must add a new Envoy.blade.php
file in the root of your project and use this configuration:
@setup
require __DIR__.'/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
try {
$dotenv->load();
$dotenv->required(['DEPLOY_USER', 'DEPLOY_SERVER', 'DEPLOY_BASE_DIR', 'DEPLOY_REPO'])->notEmpty();
} catch ( Exception $e ) {
echo $e->getMessage();
}
$user = env('DEPLOY_USER');
$repo = env('DEPLOY_REPO');
if (!isset($baseDir)) {
$baseDir = env('DEPLOY_BASE_DIR');
}
if (!isset($branch)) {
$branch = 'master';
}
$releaseDir = $baseDir . '/releases';
$currentDir = $baseDir . '/current';
$release = date('YmdHis');
$currentReleaseDir = $releaseDir . '/' . $release;
function logMessage($message) {
return "echo '\033[32m" .$message. "\033[0m';\n";
}
@endsetup
@servers(['prod' => env('DEPLOY_USER').'@'.env('DEPLOY_SERVER')])
@task('rollback', ['on' => 'prod', 'confirm' => true])
{{ logMessage("Rolling back...") }}
cd {{ $releaseDir }}
ln -nfs {{ $releaseDir }}/$(find . -maxdepth 1 -name "20*" | sort | tail -n 2 | head -n1) {{ $baseDir }}/current
{{ logMessage("Rolled back!") }}
{{ logMessage("Rebuilding cache") }}
php {{ $currentDir }}/artisan route:cache
php {{ $currentDir }}/artisan config:cache
php {{ $currentDir }}/artisan view:cache
{{ logMessage("Rebuilding cache completed") }}
echo "Rolled back to $(find . -maxdepth 1 -name "20*" | sort | tail -n 2 | head -n1)"
@endtask
@task('init', ['on' => 'prod', 'confirm' => true])
if [ ! -d {{ $baseDir }}/current ]; then
cd {{ $baseDir }}
git clone {{ $repo }} --branch={{ $branch }} --depth=1 -q {{ $release }}
{{ logMessage("Repository cloned") }}
mv {{ $release }}/storage {{ $baseDir }}/storage
ln -nfs {{ $baseDir }}/storage {{ $release }}/storage
ln -nfs {{ $baseDir }}/storage/public {{ $release }}/public/storage
{{ logMessage("Storage directory set up") }}
cp {{ $release }}/.env.example {{ $baseDir }}/.env
ln -nfs {{ $baseDir }}/.env {{ $release }}/.env
{{ logMessage("Environment file set up") }}
sudo chown -R {{ $user }}:www-data {{ $baseDir }}/storage
sudo chmod -R ug+rwx {{ $baseDir }}/storage
rm -rf {{ $release }}
{{ logMessage("Deployment path initialised. Run 'envoy run deploy' now.") }}
else
{{ logMessage("Deployment path already initialised (current symlink exists)!") }}
fi
@endtask
@story('deploy', ['on' => 'prod'])
git
composer
npm_install
npm_run_prod
update_symlinks
migrate_release
set_permissions
reload_services
cache
clean_old_releases
@endstory
@task('git')
{{ logMessage("Cloning repository") }}
git clone {{ $repo }} --branch={{ $branch }} --depth=1 -q {{ $currentReleaseDir }}
@endtask
@task('composer')
{{ logMessage("Running composer") }}
cd {{ $currentReleaseDir }}
composer install --no-interaction --quiet --no-dev --prefer-dist --optimize-autoloader
@endtask
@task('npm_install')
{{ logMessage("NPM install") }}
cd {{ $currentReleaseDir }}
npm install --silent --no-progress > /dev/null
@endtask
@task('npm_run_prod')
{{ logMessage("NPM run prod") }}
cd {{ $currentReleaseDir }}
npm run prod --silent --no-progress > /dev/null
{{ logMessage("Deleting node_modules folder") }}
rm -rf node_modules
@endtask
@task('update_symlinks')
{{ logMessage("Updating symlinks") }}
# Remove the storage directory and replace with persistent data
{{ logMessage("Linking storage directory") }}
rm -rf {{ $currentReleaseDir }}/storage;
cd {{ $currentReleaseDir }};
ln -nfs {{ $baseDir }}/storage {{ $currentReleaseDir }}/storage;
ln -nfs {{ $baseDir }}/storage/app/public {{ $currentReleaseDir }}/public/storage
# Remove the public uploads directory and replace with persistent data
# {{ logMessage("Linking uploads directory") }}
# rm -rf {{ $currentReleaseDir }}/public/uploads
# cd {{ $currentReleaseDir }}/public
# ln -nfs {{ $baseDir }}/uploads {{ $currentReleaseDir }}/uploads;
# Import the environment config
{{ logMessage("Linking .env file") }}
cd {{ $currentReleaseDir }};
ln -nfs {{ $baseDir }}/.env .env;
# Symlink the latest release to the current directory
{{ logMessage("Linking current release") }}
ln -nfs {{ $currentReleaseDir }} {{ $currentDir }};
@endtask
@task('set_permissions')
# Set dir permissions
{{ logMessage("Set permissions") }}
sudo chown -R {{ $user }}:www-data {{ $baseDir }}
sudo chmod -R ug+rwx {{ $baseDir }}/storage
cd {{ $baseDir }}
sudo chown -R {{ $user }}:www-data current
sudo chmod -R ug+rwx current/storage current/bootstrap/cache
sudo chown -R {{ $user }}:www-data {{ $currentReleaseDir }}
@endtask
@task('cache')
{{ logMessage("Building cache") }}
php {{ $currentDir }}/artisan route:cache
php {{ $currentDir }}/artisan config:cache
php {{ $currentDir }}/artisan view:cache
@endtask
@task('clean_old_releases')
# Delete all but the 5 most recent releases
{{ logMessage("Cleaning old releases") }}
cd {{ $releaseDir }}
ls -dt {{ $releaseDir }}/* | tail -n +6 | xargs -d "\n" rm -rf;
@endtask
@task('migrate_release', ['on' => 'prod', 'confirm' => false])
{{ logMessage("Running migrations") }}
php {{ $currentReleaseDir }}/artisan migrate --force
@endtask
@task('migrate', ['on' => 'prod', 'confirm' => true])
{{ logMessage("Running migrations") }}
php {{ $currentDir }}/artisan migrate --force
@endtask
@task('migrate_rollback', ['on' => 'prod', 'confirm' => true])
{{ logMessage("Rolling back migrations") }}
php {{ $currentDir }}/artisan migrate:rollback --force
@endtask
@task('migrate_status', ['on' => 'prod'])
php {{ $currentDir }}/artisan migrate:status
@endtask
@task('reload_services', ['on' => 'prod'])
# Reload Services
{{ logMessage("Restarting service supervisor") }}
sudo supervisorctl restart all
{{ logMessage("Reloading php") }}
sudo systemctl reload php7.3-fpm
@endtask
@finished
echo "Envoy deployment script finished.\r\n";
@endfinished
Also, add the following variables into your .env
file in your local project
DEPLOY_USER=deployeruser
DEPLOY_SERVER=11.22.33.44
DEPLOY_BASE_DIR=/path/project
DEPLOY_REPO=urlofyourrepo
It’s important to note that the DEPLOY_BASE_DIR
value must be an existing path in your server, inside it we will generate the structure seen above.
5. Set initial project structure into your VPS
Having an empty project folder in the server, for example /path/project
you can run into your computer, inside your project path:
envoy run init
This command will generate the initial scaffolding of your project into your server, now connect via ssh into the server, go to your project path and fill the .env
file with you needed settings.
Note: in case you get errors, maybe you will modify the Envoy init script.
6. Do a deploy from your computer
Finally, you can run the following command and see the magic:
envoy run deploy
If you need to do a rollback task, you can use this command:
envoy run rollback
If you liked this post I invite you to support inviting me a coffee