Lucas Fiege

How to do Zero Downtime deploys for a Laravel app with Envoy

Photo by Robert V. Ruggiero on Unsplash

Photo by Robert V. Ruggiero on Unsplash

TL;DR

  1. Configure SSH key into your VPS
  2. Copy the generated public key to GitLab or GitHub
  3. Access to your VPS from your computer using an SSH key
  4. Add the Envoy.blade.php file to your project
  5. Set initial project structure into your VPS
  6. 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 "youremail@example.com" -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 git@gitlab.com

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

Invitame un café en cafecito.app


almost 4 years ago

Lucas Fiege