Zero downtime Laravel deployments using Envoy

Photo by Sam Xu on Unsplash

Envoy is one of the most useful tools in the Laravel ecosystem and I use it almost every day. Nearly every project I work on gets it’s own Envoy script to makes deployments super simple.

Envoy is a task runner, and a simple one at that. It’s not Laravel specific (it’s not even PHP specific) but does make use of the blade templating language as a way to script actions. So Laravel developers will feel immediately comfortable. Although you could use Envoy to write deployment scripts for pretty much any tech you happen to be using, I’m only going to deal with Laravel here. However, I don’t think it would take much to to get it working for anything else.

Like other Laravel tools, Envoy is installed using composer and should be easy to set up as part of your CI/CD workflow. If you don’t already have it installed, then a simple composer global require laravel/envoy should be all you need.

Envoy uses a really simple configuration. For really basic sites I’ll often use something like this:

Basic Envoy script simple sites

All that really does is pull any changes on the master branch into the local copy on the server. It then runs a composer self-update and a composer install. The cp line is something that I use when dealing with Laravel on Hetzer SA. I wrote a little about that, if you’re interested.

You could also expand on this a little. For example, If you need database migrations you could add a line of php artisan migrate. It also wouldn’t be a bad idea to put your site in maintenance mode while this is all happening as it could take a little while to complete all these tasks and you don’t want visitors to see odd error messages. For Laravel apps: artisan down and artisan up are probably a good idea.

This works quite well and is a really nice, simple way to deploy basic sites. But there’s a few small problems. Firstly, you have to take your site offline, which means no one can get to it. For busy sites that’s really not something you want to do without having a REALLY good reason. You could schedule your updates for times when your site is less busy, but that’s just another task that has to be managed, and it’s STILL downtime. Well, I have a better way…

Getting to Zero

Let’s say you’ve built an app that has gained some popularity and you’re getting quite a few logins a day. Nice. Now you discover a bug that could be effecting a number of your hard earned users. So you fix the bug, run your tests and all it good, so now you need to get the changes into production. The previous example will work great, but as we know, it has a few problems. And what if more problems arrise during deployment that you didn’t plan for? It could mean your app is offline for an extended period of time. Not good.

Ultimately you need to get your new version into production without breaking what is already there. Zero downtime deployments do take a little bit of planning, but it’s actually not that hard. For Laravel apps, here’s what I do. I think you could do this with other tech or frameworks without changing all that much.

  1. Instead of serving an actual directory, make a small change and point your web server to a symlink of the application directory. You’ll probably need to make some changes to your web server config so that it will follow symbolic links.
  2. Clone your repository into a new directory.
  3. Copy the storage directory and the .env file out of the project directory and place them somewhere they won’t get lost. We’ll create links to them soon.
  4. Run composer install and, if needed, php artisan migrate --force in the new directory to install new dependencies and migrate any database changes.
  5. Create a new link inside the project directory to the storage directory you copied out earlier. Do the same with the .env file you copied so that you now have two symbolic links inside the project directory.
  6. Create the symbolic link that points to your project and that your webserver will serve.
  7. Get a coffee.

That’s it. Zero downtime deployments for Laravel. Better yet, we can put all this into an Envoy script that we can run just once. If anything goes wrong, your current deployment will remain since the symlink is created in the last step and it only happens once everything else has been completed first.

The Setup

Before you begin with all this, you’ll want to make sure that your web server config is set up to allow symbolic links. If you’re an Nginx user, you probably don’t need to do anything. For Apache, however, you’ll probably need to add something like this to your config:

Options +FollowSymLinks

Without it, Apache won’t know what to do with the symbolic links that you’ll create.

I like to put apps in the /opt directory under the name of the app. So something like /opt/my_app. I then create a sources directory. You can call it anything you like. Deployments then always go into the sources directory and get sym-linked as source (although that happens last).

Clone a new copy of your app into the sources directory. You can call it anything you want for now, but I usually stick with initial. Later on, you’ll write an Envoy script that will use the current date for the name of the directories that you clone into.

git clone -b production "git@repository.git" sources/initial
cd sources/initial

Now sort out the storage directory and the .env file.

cp /opt/my_app/sources/initial/.env.example /opt/my_app/.env
cp -r /opt/my_app/sources/initial/storage /opt/my_app/storage
rm -rf /opt/my_app/sources/initial/storage
ln -nfs /opt/my_app/.env /opt/my_app/sources/initial/.env
ln -nfs /opt/my_app/storage /opt/my_app/sources/initial/storage

Which should result in the following layout:

/opt/my_app/sources/initial
/opt/my_app/storage
/opt/my_app/.env
/opt/my_app/sources/initial/.env  — > /opt/my_app/.env
/opt/my_app/sources/initial/storage — > /opt/my_app/storage

We’re into the home stretch.

composer install -o --prefer-dist --no-dev
php ./artisan migrate --force

Any Laravel developer should be fairly comfortable with that. The composer line will install composer dependencies and optimize the autoloader. Follow that up by making sure that any database changes are migrated. Don’t forget to generate a new key and make any changes to the .env file that you need.

php ./artisan key:generate

Everything should be up and running now. The point of all of this is that you can now clone the repository into a new directory and simply replace the source link and your new version will be up and running.

ln -nfs /opt/my_app/sources/intial /opt/my_app/source

Your web server should now serve the source link.

Envoy

All of this is great, but it’s not exactly a quick process. And it’s complex enough that mistakes can easily creep in. This is where Envoy steps in. You can take everything that you’ve now done and stick it into an Envoy script which will do everything automatically. Here’s the basic layout I like to use for my Envoy script:

@servers(['production' => 'user@server.com'])
@setup
// any setup will go here
@endsetup
@story('deploy')
git
install
live
@endstory
@task('git', ['on' => 'production'])
@endtask
@task('install', ['on' => 'production'])
@endtask
@task('live', ['on' => 'production'])
@endtask

Since Envoy is a task runner, we can separate our deployment script into tasks. You don’t really need to do this, though. You could have just one task that does everything. The @setup block allows for any setting up you might need to do, like defining a few variables that can be used in the tasks. I’ll often use something like this:

@setup
$repo = 'git@repo.git';
$appDir = '/opt/my_app';
$branch = 'production';
    date_default_timezone_set('Africa/Johannesburg');
$date = date('YmdHis');
    $builds = $appDir . '/sources';
$deployment = $builds . '/' . $date;
    $serve = $appDir . '/source';
$env = $appDir . '/.env';
$storage = $appDir . '/storage';
@endsetup

This should be fairly self-explanatory. I’m creating a bunch of variables that I can use in the tasks just to make life a little easier. It also makes the script a little more reusable since I only need to change a few values under setup instead of throughout the script. The $date variable is used to name the directory we’ll clone the repo into and help ensure we’re not overwriting anything.

Time to flesh out the tasks. First the git task:

@task('git', ['on' => 'production'])
git clone -b {{ $branch }} "{{ $repo }}" {{ $deployment }}
@endtask

Fairly simple stuff. Note how the variables from setup now get used. Here’s the install task:

@task('install', ['on' => 'production'])
cd {{ $deployment }}
    rm -rf {{ $deployment }}/storage
    ln -nfs {{ $env }} {{ $deployment}}/.env
    ln -nfs {{ $storage }} {{ $deployment }}/storage
    composer install --prefer-dist --no-dev
    php ./artisan migrate --force
@endtask

And finally the live task:

@task('live' ['on' => 'production'])
ln -nfs {{ $deployment }} {{ $serve }}
@endtask

If you wanted to, you could run each task from the command line. However, it’s way nicer to add a @story block so that all the tasks are run in sequence:

@story('deploy')
git
install
live
@endstory

You’re done! Here’s the script in full:

When you’re ready to deploy, all you need is:

envoy run deploy

If you think about it, the only variables that would need changing between different projects would be the $repo, $appDir, and maybe $branch. Simply copy the Envoy.blade.php file, and update the variables as needed.

And Finally

There’s a few added benefits to deploying this way. Obviously the zero-downtime is a massive plus, but you also suddenly get a copy of the previous deployment still sitting on your server.

Say you get through a deployment only to find that there’s been a horible mistake (god forbid). You can easily roll back to the previous deployment just by changing the source link. Using a date string to name those directories means that it’s also really simple to find the previous one.

Alternatively, instead of simply replacing the source link, you could rename it to something like backup and then create the source link to your new deployment. If you need to go back, simply delete the current source link an rename backup to source.

And if the deployment fails for some reason, no need to worry since the actual “making it live” bit is done right at the end. If something does go wrong, Envoy will bail out with something like this:

Envoy task failure

The previous deployment is still running and untouched.

From here, the script could be modified to deploy to your testing and staging environments. Have a read through the Envoy documentation here. There’s a few other features that really make it super handy. You can even get it to notify your Slack channel.